Compare commits

..
Author SHA1 Message Date
Flaminel ad8c5f23cf fixed some early returns causing a retry 2026-06-17 15:47:06 +03:00
Flaminel 9eebeed990 fixed log again 2026-06-17 15:46:32 +03:00
Flaminel a79a60a339 fixed ignored downloads bloating with each run 2026-06-17 15:27:32 +03:00
Flaminel d1bd9fddcc retried webhook malware scans while the torrent metadata is not yet available 2026-06-17 15:06:25 +03:00
Flaminel 96823adcc3 reworked webhooks controller to ProblemDetails errors and [controller] route 2026-06-17 12:14:31 +03:00
Flaminel e0e88147aa added more retries 2026-06-17 12:05:45 +03:00
Flaminel f278a0dad0 fixed log 2026-06-17 12:05:35 +03:00
Flaminel f61300b869 fixed docs link 2026-06-16 23:51:01 +03:00
Flaminel 561c05778c showed only the instance id on the arr card instead of the full webhook url 2026-06-16 23:47:11 +03:00
Flaminel 60d273991d used an enum for arr webhook event type instead of hardcoded strings 2026-06-16 23:43:18 +03:00
Flaminel ddb1042ca5 mapped malware-blocker triggerMode help key to its docs anchor 2026-06-16 23:29:07 +03:00
Flaminel 9a31e86ad8 removed unused method 2026-06-16 23:21:56 +03:00
Flaminel 614e97313e passed WebhookScanTarget to ScheduleMalwareBlockerWebhookRetry instead of individual fields 2026-06-16 23:21:56 +03:00
Flaminel a34a3d3c7e made MalwareBlocker webhook retries conditional on the download not being found 2026-06-16 23:21:56 +03:00
Flaminel f9588d89c0 changed some stuff 2026-06-16 23:21:56 +03:00
Flaminel eacd9346a5 generalized MalwareBlockerTriggerMode to JobTriggerMode 2026-06-16 23:21:56 +03:00
Flaminel 0561c64ddf added tests for MalwareBlocker webhook triggering 2026-06-16 23:21:56 +03:00
Flaminel 304a8e78ee documented MalwareBlocker webhook triggering 2026-06-16 23:21:56 +03:00
Flaminel e008b64a1d added MalwareBlocker webhook frontend (trigger mode and per-instance URL) 2026-06-16 23:21:56 +03:00
Flaminel 4f7e2d33b4 added webhook-triggered targeted MalwareBlocker scan 2026-06-16 23:21:56 +03:00
Flaminel b1b19e5f29 added content-id filter to arr queue fetch 2026-06-16 23:21:56 +03:00
Flaminel 40ab0e9fad added MalwareBlocker trigger mode config and migration 2026-06-16 23:21:56 +03:00
309 changed files with 28910 additions and 18551 deletions

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '26'
node-version: '24'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
@@ -1,12 +1,9 @@
name: Deploy to Cloudflare Pages
on:
workflow_call:
inputs:
version:
description: 'Release version (e.g. v1.2.3)'
type: string
required: true
push:
tags:
- "v*.*.*"
jobs:
deploy:
@@ -17,7 +14,7 @@ jobs:
- name: Create status files
run: |
mkdir -p status
echo "{ \"version\": \"${{ inputs.version }}\" }" > status/status.json
echo "{ \"version\": \"${GITHUB_REF_NAME}\" }" > status/status.json
# Cache static files for 10 minutes
cat > status/_headers << 'EOF'
+4 -2
View File
@@ -1,7 +1,9 @@
name: Deploy Docusaurus to GitHub Pages
on:
workflow_call: {}
push:
tags:
- "v*.*.*"
workflow_dispatch: {}
permissions:
@@ -25,7 +27,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 26.x
node-version: 24.x
cache: yarn
cache-dependency-path: docs/yarn.lock
+2 -4
View File
@@ -24,7 +24,6 @@ permissions:
jobs:
e2e:
if: false
runs-on: ubuntu-latest
timeout-minutes: 20
@@ -64,7 +63,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 26
node-version: 22
- name: Install E2E dependencies
working-directory: e2e
@@ -72,8 +71,7 @@ jobs:
- name: Install Playwright browsers
working-directory: e2e
timeout-minutes: 5
run: npx playwright install chromium
run: npx playwright install --with-deps chromium
- name: Wait for Keycloak
run: |
+1 -22
View File
@@ -99,8 +99,7 @@ jobs:
# Run E2E tests
e2e:
needs: validate
# if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
if: false
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
uses: ./.github/workflows/e2e.yml
secrets: inherit
@@ -243,26 +242,6 @@ jobs:
./artifacts/*.pkg
./artifacts/*.exe
# Deploy docs after a successful release
deploy-docs:
needs: [create-release]
if: needs.create-release.result == 'success'
permissions:
contents: read
pages: write
id-token: write
uses: ./.github/workflows/docs.yml
secrets: inherit
# Deploy Cloudflare status page after a successful release
deploy-status:
needs: [validate, create-release]
if: needs.create-release.result == 'success'
uses: ./.github/workflows/cloudflare-pages-status.yml
with:
version: ${{ needs.validate.outputs.release_version }}
secrets: inherit
# Summary job
summary:
needs: [validate, test, e2e, build-frontend, build-executables, build-windows-installer, build-macos, build-docker]
+5 -11
View File
@@ -46,13 +46,11 @@ Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in
- Always use **NSubstitute** for mocking in new tests (Moq is being phased out)
### Frontend
- **Angular 22** with TypeScript 6.0, Node 26 (standalone components, zoneless, OnPush)
- **Angular 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
- **UI**: Custom glassmorphism design system with 33 custom components — no external UI frameworks
- **Icons**: @ng-icons/core + @ng-icons/tabler-icons
- **Design System**: 3-layer SCSS (`_variables` -> `_tokens` -> `_themes`), dark/light themes
- **State Management**: Angular signals (`signal`/`computed`/`effect`) — `@ngrx/signals` was removed (it was unused)
- **Data fetching**: Angular 22 Resource API — `rxResource` from `@angular/core/rxjs-interop` (not manual `HttpClient.subscribe()`)
- **Forms**: Angular 22 Signal Forms — `form()` + `[formField]` from `@angular/forms/signals` (settings forms; a few not-yet-migrated forms still use per-field signals)
- **State Management**: @ngrx/signals (Angular signals-based)
- **Real-time Updates**: @microsoft/signalr 10.0.0
- **PWA**: Service Worker support enabled
@@ -71,7 +69,7 @@ Cleanuparr/
│ │ ├── Cleanuparr.Persistence/ # SQLite data access
│ │ ├── Cleanuparr.Persistence.Tests/
│ │ └── Cleanuparr.Shared/ # Shared utilities
│ ├── frontend/ # Angular 22 application
│ ├── frontend/ # Angular 21 application
│ ├── e2e/ # Playwright E2E tests
│ ├── Dockerfile # Multi-stage Docker build
│ ├── entrypoint.sh # Docker entrypoint
@@ -100,8 +98,6 @@ Cleanuparr/
- All components must be **standalone** with **ChangeDetectionStrategy.OnPush**
- Use `input()` / `output()` function APIs (not `@Input()` / `@Output()` decorators)
- Use Angular **signals** for reactive state (`signal()`, `computed()`, `effect()`)
- **Data fetching**: use the **Resource API** (`rxResource`) with a reactive `params` + `stream`, not manual `HttpClient.subscribe()`; drive spinners/errors off `isLoading()`/`error()`
- **Forms**: use **Signal Forms** (`form()` + `[formField]`) with a single model signal + schema validators; keep the JSON-snapshot dirty tracking (`buildSnapshot()`/`hasPendingChanges()`), do NOT use Signal Forms `dirty()` for the unsaved-changes guard
- Follow the 3-layer SCSS design system (`_variables` -> `_tokens` -> `_themes`)
- **Do not introduce external UI frameworks** (no PrimeNG, Material, Tailwind, etc.)
- Component naming: `{feature}.component.ts`
@@ -187,9 +183,7 @@ make migrate-users name=YourMigrationName
- **Malware blocker** is a critical security feature - changes require careful testing
- **Cross-seed integration** allows keeping torrents that are actively seeding
- **Real-time updates** use SignalR - maintain websocket patterns when adding features
- Use `@ng-icons/core` + `@ng-icons/tabler-icons` for icons (NOT `angular-tabler-icons` which doesn't support Angular 22)
- Use `@ng-icons/core` + `@ng-icons/tabler-icons` for icons (NOT `angular-tabler-icons` which doesn't support Angular 21)
- **Sidebar** stays dark purple in both themes - uses sidebar-specific CSS variables
- The project uses **Clean Architecture** - respect layer boundaries
- **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`) — keep this even with Signal Forms; Signal Forms `dirty()` means "touched", not "differs from saved"
- **Resource API** (`rxResource`): `value()` throws in the error state — always set a `defaultValue` (lists) or guard with `hasValue()` before reading
- **Signal Forms** (`[formField]`) owns `min`/`max`/`disabled`/`required` — set these via schema validators, not template bindings. Custom controls satisfy the contract via `model()` signals (`chip-input` exposes a `value` model; `size-input`'s numeric-min input is named `minValue` to avoid clashing with the field min)
- **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`)
+1 -1
View File
@@ -27,7 +27,7 @@ This helps us avoid redundant work, git conflicts, and contributions that may no
### Prerequisites
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- [Node.js 26+](https://nodejs.org/)
- [Node.js 18+](https://nodejs.org/)
- [Git](https://git-scm.com/)
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations
- (Optional) IDE: [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio](https://visualstudio.microsoft.com/)
+1 -1
View File
@@ -1,5 +1,5 @@
# Build Angular frontend
FROM --platform=$BUILDPLATFORM node:26-alpine AS frontend-build
FROM --platform=$BUILDPLATFORM node:25-alpine AS frontend-build
WORKDIR /app
# Copy package files first for better layer caching
@@ -1,101 +0,0 @@
using Cleanuparr.Api.Contracts.Responses;
using Cleanuparr.Api.Controllers;
using Cleanuparr.Api.Features.Events.Contracts.Responses;
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.AspNetCore.Mvc;
using Shouldly;
namespace Cleanuparr.Api.Tests.Controllers;
/// <summary>
/// Verifies the events list endpoint's ordering, filtering, search, and primitive-collection round-tripping.
/// Runs against real SQLite so the projection is actually translated.
/// </summary>
public class EventsControllerMergeTests : IDisposable
{
private readonly EventsContext _context;
private readonly EventsController _controller;
public EventsControllerMergeTests()
{
_context = SeekerTestDataFactory.CreateEventsContext();
_controller = new EventsController(_context);
}
public void Dispose()
{
_context.Dispose();
GC.SuppressFinalize(this);
}
private async Task SeedAsync()
{
_context.Events.Add(new AppEvent
{
EventType = EventType.FailedImportStrike,
Message = "active",
Severity = EventSeverity.Important,
Timestamp = DateTimeOffset.UtcNow.AddDays(-1),
ItemTitle = "Active Item",
FailedImportReasons = ["reason one", "reason two"],
GrabbedItems = ["grab one"],
});
_context.Events.Add(new AppEvent
{
EventType = EventType.QueueItemDeleted,
Message = "archived",
Severity = EventSeverity.Important,
Timestamp = DateTimeOffset.UtcNow.AddDays(-100),
ItemTitle = "Archived Item",
});
await _context.SaveChangesAsync();
}
private static PaginatedResult<EventListItem> GetPage(ActionResult<PaginatedResult<EventListItem>> action)
{
OkObjectResult ok = action.Result.ShouldBeOfType<OkObjectResult>();
return ok.Value.ShouldBeOfType<PaginatedResult<EventListItem>>();
}
[Fact]
public async Task GetEvents_OrdersNewestFirst_AndRoundTripsCollections()
{
await SeedAsync();
PaginatedResult<EventListItem> page = GetPage(await _controller.GetEvents());
page.TotalCount.ShouldBe(2);
page.Items.Count.ShouldBe(2);
page.Items[0].Message.ShouldBe("active"); // newer
page.Items[1].Message.ShouldBe("archived");
// The primitive-collection columns must survive the Concat projection.
page.Items[0].FailedImportReasons.ShouldBe(["reason one", "reason two"]);
page.Items[0].GrabbedItems.ShouldBe(["grab one"]);
}
[Fact]
public async Task GetEvents_EventTypeFilter_Applies()
{
await SeedAsync();
PaginatedResult<EventListItem> page = GetPage(await _controller.GetEvents(eventType: nameof(EventType.QueueItemDeleted)));
page.TotalCount.ShouldBe(1);
page.Items[0].Message.ShouldBe("archived");
}
[Fact]
public async Task GetEvents_SearchFilter_MatchesArchivedItemTitle()
{
await SeedAsync();
PaginatedResult<EventListItem> page = GetPage(await _controller.GetEvents(search: "Archived"));
page.TotalCount.ShouldBe(1);
page.Items[0].Message.ShouldBe("archived");
}
}
@@ -1,134 +0,0 @@
using Cleanuparr.Api.Controllers;
using Cleanuparr.Api.Features.Events.Contracts.Responses;
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.AspNetCore.Mvc;
using Shouldly;
namespace Cleanuparr.Api.Tests.Controllers;
public class EventsControllerTimelineTests : IDisposable
{
private readonly EventsContext _context;
private readonly EventsController _controller;
public EventsControllerTimelineTests()
{
_context = SeekerTestDataFactory.CreateEventsContext();
_controller = new EventsController(_context);
}
public void Dispose()
{
_context.Dispose();
GC.SuppressFinalize(this);
}
private static EventTypeTimelineResponse GetTimeline(ActionResult<EventTypeTimelineResponse> action)
{
OkObjectResult ok = action.Result.ShouldBeOfType<OkObjectResult>();
return ok.Value.ShouldBeOfType<EventTypeTimelineResponse>();
}
[Fact]
public async Task GetTimeline_BucketsEventsByTypeAndDay()
{
DateOnly today = DateOnly.FromDateTime(DateTimeOffset.UtcNow.UtcDateTime);
DateTimeOffset sameDay = new(today.ToDateTime(new TimeOnly(12, 0)), TimeSpan.Zero);
_context.Events.Add(new AppEvent
{
EventType = EventType.FailedImportStrike,
Message = "active a",
Severity = EventSeverity.Important,
Timestamp = sameDay,
});
_context.Events.Add(new AppEvent
{
EventType = EventType.FailedImportStrike,
Message = "active b",
Severity = EventSeverity.Important,
Timestamp = sameDay.AddHours(-1),
});
_context.Events.Add(new AppEvent
{
EventType = EventType.StalledStrike,
Message = "active c",
Severity = EventSeverity.Important,
Timestamp = sameDay.AddHours(-2),
});
_context.Events.Add(new AppEvent
{
EventType = EventType.QueueItemDeleted,
Message = "older removal",
Severity = EventSeverity.Important,
Timestamp = DateTimeOffset.UtcNow.AddDays(-10),
});
await _context.SaveChangesAsync();
EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24 * 30));
timeline.Types.ShouldBe(["FailedImportStrike", "StalledStrike", "QueueItemDeleted"]);
int failedImport = timeline.Buckets.Sum(b => b.Counts.GetValueOrDefault("FailedImportStrike"));
int stalled = timeline.Buckets.Sum(b => b.Counts.GetValueOrDefault("StalledStrike"));
int removed = timeline.Buckets.Sum(b => b.Counts.GetValueOrDefault("QueueItemDeleted"));
failedImport.ShouldBe(2);
stalled.ShouldBe(1);
removed.ShouldBe(1);
DateTimeOffset todayStart = new(today.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
EventTypeTimelineBucket todayBucket = timeline.Buckets.Single(b => b.Date == todayStart);
todayBucket.Counts["FailedImportStrike"].ShouldBe(2);
todayBucket.Counts["StalledStrike"].ShouldBe(1);
todayBucket.Counts.ShouldNotContainKey("QueueItemDeleted");
}
[Fact]
public async Task GetTimeline_UsesHourlyBucketsForDayWindow()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
_context.Events.Add(new AppEvent
{
EventType = EventType.StalledStrike,
Message = "recent",
Severity = EventSeverity.Important,
Timestamp = now.AddHours(-1),
});
_context.Events.Add(new AppEvent
{
EventType = EventType.StalledStrike,
Message = "earlier",
Severity = EventSeverity.Important,
Timestamp = now.AddHours(-3),
});
await _context.SaveChangesAsync();
EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24));
int nonEmpty = timeline.Buckets.Count(b => b.Counts.GetValueOrDefault("StalledStrike") > 0);
nonEmpty.ShouldBe(2);
timeline.Buckets.Count.ShouldBeGreaterThan(2);
}
[Fact]
public async Task GetTimeline_ExcludesEventsOutsideWindow()
{
_context.Events.Add(new AppEvent
{
EventType = EventType.QueueItemDeleted,
Message = "too old",
Severity = EventSeverity.Important,
Timestamp = DateTimeOffset.UtcNow.AddDays(-40),
});
await _context.SaveChangesAsync();
EventTypeTimelineResponse timeline = GetTimeline(await _controller.GetTimeline(hours: 24 * 7));
timeline.Types.ShouldBeEmpty();
timeline.Buckets.ShouldAllBe(b => b.Counts.Count == 0);
}
}
@@ -1,66 +0,0 @@
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Shouldly;
namespace Cleanuparr.Api.Tests.Events;
/// <summary>
/// Exercises the manual-event partial unique index against a real SQLite context configured with the
/// production naming conventions. The EF Core InMemory provider ignores unique indexes, so the guarantee
/// that <see cref="Cleanuparr.Infrastructure.Events.EventPublisher.PublishManualAsync"/> relies on to
/// dedup racing publishers can only be verified here.
/// </summary>
public class ManualEventDedupTests : IDisposable
{
private readonly EventsContext _context;
public ManualEventDedupTests()
{
_context = SeekerTestDataFactory.CreateEventsContext();
}
public void Dispose()
{
_context.Dispose();
GC.SuppressFinalize(this);
}
private static ManualEvent NewEvent(string hash, bool isResolved) => new()
{
Type = ManualEventType.RecurringDownload,
Message = "m",
Severity = EventSeverity.Warning,
ItemHash = hash,
IsResolved = isResolved,
};
[Fact]
public async Task TwoUnresolvedSameTypeAndHash_ViolatesUniqueIndex_WithSqliteConstraintError()
{
_context.ManualEvents.Add(NewEvent("abc123", isResolved: false));
_context.ManualEvents.Add(NewEvent("abc123", isResolved: false));
// The exception must surface as SQLITE_CONSTRAINT (19) — the exact code PublishManualAsync's
// catch filters on to treat the loser of a race as deduped.
DbUpdateException ex = await Should.ThrowAsync<DbUpdateException>(() => _context.SaveChangesAsync());
SqliteException sqliteEx = ex.InnerException.ShouldBeOfType<SqliteException>();
sqliteEx.SqliteErrorCode.ShouldBe(19);
}
[Fact]
public async Task ResolvedDuplicate_IsExemptFromUniqueIndex()
{
_context.ManualEvents.Add(NewEvent("abc123", isResolved: false));
await _context.SaveChangesAsync();
// The index is filtered on "is_resolved = 0", so a resolved row with the same type/hash is allowed.
_context.ManualEvents.Add(NewEvent("abc123", isResolved: true));
await Should.NotThrowAsync(() => _context.SaveChangesAsync());
(await _context.ManualEvents.CountAsync()).ShouldBe(2);
}
}
@@ -279,16 +279,21 @@ public class SearchStatsControllerTests : IDisposable
Timestamp = timestamp ?? DateTime.UtcNow
};
if (itemTitle is not null)
{
appEvent.ItemTitle = itemTitle;
appEvent.SearchType = searchType;
appEvent.SearchReason = searchReason;
appEvent.GrabbedItems = grabbedItems ?? [];
}
_eventsContext.Events.Add(appEvent);
_eventsContext.SaveChanges();
if (itemTitle is not null)
{
_eventsContext.SearchEventData.Add(new SearchEventData
{
AppEventId = appEvent.Id,
ItemTitle = itemTitle,
SearchType = searchType,
SearchReason = searchReason,
GrabbedItems = grabbedItems ?? [],
});
_eventsContext.SaveChanges();
}
}
#endregion
@@ -0,0 +1,164 @@
using Cleanuparr.Api.Features.Webhooks.Contracts;
using Cleanuparr.Api.Features.Webhooks.Controllers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Webhooks;
public class WebhooksControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagement;
private readonly WebhooksController _controller;
private Guid _sonarrInstanceId;
private Guid _lidarrInstanceId;
public WebhooksControllerTests()
{
_dataContext = CreateDataContext();
_jobManagement = Substitute.For<IJobManagementService>();
var logger = Substitute.For<ILogger<WebhooksController>>();
_controller = new WebhooksController(logger, _dataContext, _jobManagement);
ControllerTestContext.Attach(_controller);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
private DataContext CreateDataContext()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<DataContext>().UseSqlite(connection).Options;
var context = new DataContext(options);
context.Database.EnsureCreated();
var sonarrInstance = new ArrInstance { Enabled = true, Name = "Sonarr", Url = new Uri("http://sonarr:8989"), ApiKey = "key" };
var lidarrInstance = new ArrInstance { Enabled = true, Name = "Lidarr", Url = new Uri("http://lidarr:8686"), ApiKey = "key" };
_sonarrInstanceId = sonarrInstance.Id;
_lidarrInstanceId = lidarrInstance.Id;
context.ArrConfigs.AddRange(
new ArrConfig { Type = InstanceType.Sonarr, Instances = [sonarrInstance] },
new ArrConfig { Type = InstanceType.Lidarr, Instances = [lidarrInstance] }
);
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
{
Enabled = true,
TriggerMode = JobTriggerMode.Both,
IgnoredDownloads = [],
});
context.SaveChanges();
return context;
}
private void SetConfig(bool enabled, JobTriggerMode mode)
{
var config = _dataContext.ContentBlockerConfigs.First();
config.Enabled = enabled;
config.TriggerMode = mode;
_dataContext.SaveChanges();
}
private static ArrWebhookPayload GrabPayload(string? downloadId = "HASH123", long seriesId = 42) => new()
{
EventType = "Grab",
DownloadId = downloadId,
Series = new ArrWebhookContent { Id = seriesId },
};
[Fact]
public async Task TestEvent_ReturnsOk_AndDoesNotSchedule()
{
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, new ArrWebhookPayload { EventType = "Test" });
result.ShouldBeOfType<OkResult>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
[Fact]
public async Task ValidGrab_SchedulesTargetedScan()
{
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload());
result.ShouldBeOfType<OkResult>();
await _jobManagement.Received(1)
.TriggerMalwareBlockerWebhook(_sonarrInstanceId, "HASH123", 42, InstanceType.Sonarr);
}
[Fact]
public async Task UnknownInstance_ReturnsNotFound()
{
var result = await _controller.TriggerMalwareBlocker(Guid.NewGuid(), GrabPayload());
var notFound = result.ShouldBeOfType<ObjectResult>();
notFound.StatusCode.ShouldBe(StatusCodes.Status404NotFound);
notFound.Value.ShouldBeOfType<ProblemDetails>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
[Fact]
public async Task NonSonarrRadarrInstance_ReturnsUnprocessable()
{
var result = await _controller.TriggerMalwareBlocker(_lidarrInstanceId, GrabPayload());
var unprocessable = result.ShouldBeOfType<ObjectResult>();
unprocessable.StatusCode.ShouldBe(StatusCodes.Status422UnprocessableEntity);
unprocessable.Value.ShouldBeOfType<ProblemDetails>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
[Fact]
public async Task Disabled_ReturnsOk_AndDoesNotSchedule()
{
SetConfig(enabled: false, JobTriggerMode.Both);
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload());
result.ShouldBeOfType<OkResult>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
[Fact]
public async Task ScheduleOnlyMode_ReturnsOk_AndDoesNotSchedule()
{
SetConfig(enabled: true, JobTriggerMode.Schedule);
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload());
result.ShouldBeOfType<OkResult>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
[Fact]
public async Task EmptyDownloadId_ReturnsOk_AndDoesNotSchedule()
{
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload(downloadId: null));
result.ShouldBeOfType<OkResult>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
}
@@ -1,10 +0,0 @@
namespace Cleanuparr.Api.Common;
public static class TimelineWindow
{
public const int MinHours = 1;
public const int MaxHours = 8760;
public static int ClampHours(int hours) => Math.Clamp(hours, MinHours, MaxHours);
}
@@ -1,22 +0,0 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Api.Contracts.Responses;
public class PaginatedResult<T>
{
public List<T> Items { get; set; } = new();
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public int TotalPages { get; set; }
[JsonIgnore]
public bool HasPrevious => Page > 1;
[JsonIgnore]
public bool HasNext => Page < TotalPages;
}
@@ -1,9 +1,5 @@
using System.Globalization;
using Cleanuparr.Api.Common;
using Cleanuparr.Api.Contracts.Responses;
using Cleanuparr.Api.Features.Events.Contracts.Responses;
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Stats;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.AspNetCore.Authorization;
@@ -28,7 +24,7 @@ public class EventsController : ControllerBase
/// Gets events with pagination and filtering
/// </summary>
[HttpGet]
public async Task<ActionResult<PaginatedResult<EventListItem>>> GetEvents(
public async Task<ActionResult<PaginatedResult<AppEvent>>> GetEvents(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] string? severity = null,
@@ -53,40 +49,35 @@ public class EventsController : ControllerBase
{
pageSize = 500;
}
IQueryable<EventListItem> query = _context.Events
.Select(EventListItem.FromEvent);
var query = _context.Events.AsQueryable();
// Apply filters
if (!string.IsNullOrWhiteSpace(severity))
{
if (Enum.TryParse<EventSeverity>(severity, true, out EventSeverity severityEnum))
{
if (Enum.TryParse<EventSeverity>(severity, true, out var severityEnum))
query = query.Where(e => e.Severity == severityEnum);
}
}
if (!string.IsNullOrWhiteSpace(eventType))
{
if (Enum.TryParse<EventType>(eventType, true, out EventType eventTypeEnum))
{
if (Enum.TryParse<EventType>(eventType, true, out var eventTypeEnum))
query = query.Where(e => e.EventType == eventTypeEnum);
}
}
// Apply date range filters
if (fromDate.HasValue)
{
query = query.Where(e => e.Timestamp >= fromDate.Value);
}
if (toDate.HasValue)
{
query = query.Where(e => e.Timestamp <= toDate.Value);
}
// Apply job run ID exact-match filter
if (!string.IsNullOrWhiteSpace(jobRunId) && Guid.TryParse(jobRunId, out Guid jobRunGuid))
if (!string.IsNullOrWhiteSpace(jobRunId) && Guid.TryParse(jobRunId, out var jobRunGuid))
{
query = query.Where(e => e.JobRunId == jobRunGuid);
}
@@ -97,28 +88,28 @@ public class EventsController : ControllerBase
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
(e.ItemTitle != null && EF.Functions.Like(e.ItemTitle, pattern)) ||
EF.Functions.Like(e.Data, pattern) ||
EF.Functions.Like(e.TrackingId.ToString(), pattern) ||
EF.Functions.Like(e.JobRunId.ToString(), pattern)
);
}
// Count total matching records for pagination
int totalCount = await query.CountAsync();
var totalCount = await query.CountAsync();
// Calculate pagination
int totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
int skip = (page - 1) * pageSize;
List<EventListItem> events = await query
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var skip = (page - 1) * pageSize;
// Get paginated data
var events = await query
.OrderByDescending(e => e.Timestamp)
.ThenByDescending(e => e.Id)
.Skip(skip)
.Take(pageSize)
.ToListAsync();
// Return paginated result
PaginatedResult<EventListItem> result = new()
var result = new PaginatedResult<AppEvent>
{
Items = events,
Page = page,
@@ -126,7 +117,7 @@ public class EventsController : ControllerBase
TotalCount = totalCount,
TotalPages = totalPages
};
return Ok(result);
}
@@ -137,7 +128,7 @@ public class EventsController : ControllerBase
public async Task<ActionResult<AppEvent>> GetEvent(Guid id)
{
var eventEntity = await _context.Events.FindAsync(id);
if (eventEntity == null)
return NotFound();
@@ -158,6 +149,21 @@ public class EventsController : ControllerBase
return Ok(events);
}
/// <summary>
/// Manually triggers cleanup of old events
/// </summary>
[HttpPost("cleanup")]
public async Task<ActionResult<object>> CleanupOldEvents([FromQuery] int retentionDays = 30)
{
var cutoffDate = DateTimeOffset.UtcNow.AddDays(-retentionDays);
await _context.Events
.Where(e => e.Timestamp < cutoffDate)
.ExecuteDeleteAsync();
return Ok();
}
/// <summary>
/// Gets unique event types
/// </summary>
@@ -177,68 +183,48 @@ public class EventsController : ControllerBase
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
return Ok(severities);
}
}
[HttpGet("timeline")]
public async Task<ActionResult<EventTypeTimelineResponse>> GetTimeline([FromQuery] int hours = 720)
{
hours = TimelineWindow.ClampHours(hours);
DateTimeOffset now = DateTimeOffset.UtcNow;
DateTimeOffset cutoff = now.AddHours(-hours);
TimelineBucketSize size = TimelineBucketing.DefaultFor(hours);
string cutoffText = cutoff.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture);
string bucketExpr = TimelineBucketing.BucketExpr(size);
List<BucketTypeCount> rows = await _context.Database
.SqlQueryRaw<BucketTypeCount>(
$$"""
SELECT {{bucketExpr}} AS "bucket", event_type AS "event_type", COUNT(*) AS "count"
FROM events
WHERE timestamp >= {0}
GROUP BY {{bucketExpr}}, event_type
""",
cutoffText)
.ToListAsync();
Dictionary<(DateTimeOffset Bucket, EventType Type), int> byBucketType = new();
HashSet<EventType> presentSet = [];
foreach (BucketTypeCount row in rows)
{
DateTimeOffset bucket = TimelineBucketing.ParseKey(row.Bucket, size);
EventType type = Enum.Parse<EventType>(row.EventType, ignoreCase: true);
byBucketType[(bucket, type)] = row.Count;
presentSet.Add(type);
}
List<EventType> presentTypes = presentSet
.OrderBy(t => (int)t)
.ToList();
List<EventTypeTimelineBucket> buckets = [];
foreach (DateTimeOffset bucket in TimelineBucketing.Buckets(cutoff, now, size))
{
Dictionary<string, int> counts = new();
foreach (EventType type in presentTypes)
{
if (byBucketType.TryGetValue((bucket, type), out int count) && count > 0)
{
counts[type.ToString()] = count;
}
}
buckets.Add(new EventTypeTimelineBucket { Date = bucket, Counts = counts });
}
return Ok(new EventTypeTimelineResponse
{
Types = presentTypes.Select(t => t.ToString()).ToList(),
Buckets = buckets,
});
}
private sealed class BucketTypeCount
{
public string Bucket { get; set; } = string.Empty;
public string EventType { get; set; } = string.Empty;
public int Count { get; set; }
}
}
/// <summary>
/// Represents a paginated result set
/// </summary>
/// <typeparam name="T">Type of items in the result</typeparam>
public class PaginatedResult<T>
{
/// <summary>
/// The items in the current page
/// </summary>
public List<T> Items { get; set; } = new();
/// <summary>
/// Current page number (1-based)
/// </summary>
public int Page { get; set; }
/// <summary>
/// Number of items per page
/// </summary>
public int PageSize { get; set; }
/// <summary>
/// Total number of items across all pages
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// Total number of pages
/// </summary>
public int TotalPages { get; set; }
/// <summary>
/// Whether there is a previous page
/// </summary>
[JsonIgnore]
public bool HasPrevious => Page > 1;
/// <summary>
/// Whether there is a next page
/// </summary>
[JsonIgnore]
public bool HasNext => Page < TotalPages;
}
@@ -1,4 +1,3 @@
using Cleanuparr.Api.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
@@ -80,7 +79,7 @@ public class ManualEventsController : ControllerBase
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
(e.ItemTitle != null && EF.Functions.Like(e.ItemTitle, pattern))
EF.Functions.Like(e.Data, pattern)
);
}
@@ -137,28 +136,11 @@ public class ManualEventsController : ControllerBase
return NotFound();
eventEntity.IsResolved = true;
eventEntity.ResolvedAt = DateTimeOffset.UtcNow;
await _context.SaveChangesAsync();
return Ok();
}
/// <summary>
/// Marks all unresolved manual events as resolved
/// </summary>
[HttpPost("resolve_all")]
public async Task<ActionResult<object>> ResolveAllManualEvents()
{
DateTimeOffset resolvedAt = DateTimeOffset.UtcNow;
int resolvedCount = await _context.ManualEvents
.Where(e => !e.IsResolved)
.ExecuteUpdateAsync(setter => setter
.SetProperty(e => e.IsResolved, true)
.SetProperty(e => e.ResolvedAt, resolvedAt));
return Ok(new { ResolvedCount = resolvedCount });
}
/// <summary>
/// Gets manual event statistics
/// </summary>
@@ -193,4 +175,19 @@ public class ManualEventsController : ControllerBase
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
return Ok(severities);
}
/// <summary>
/// Manually triggers cleanup of old resolved events
/// </summary>
[HttpPost("cleanup")]
public async Task<ActionResult<object>> CleanupOldResolvedEvents([FromQuery] int retentionDays = 30)
{
var cutoffDate = DateTimeOffset.UtcNow.AddDays(-retentionDays);
var deletedCount = await _context.ManualEvents
.Where(e => e.IsResolved && e.Timestamp < cutoffDate)
.ExecuteDeleteAsync();
return Ok(new { DeletedCount = deletedCount });
}
}
@@ -5,16 +5,13 @@ using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Controllers;
/// <summary>
/// Aggregated statistics endpoint for dashboard integrations.
/// Deprecated. Use <c>GET /api/v2/stats</c> instead.
/// Aggregated statistics endpoint for dashboard integrations
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class StatsController : ControllerBase
{
private static readonly DateTimeOffset SunsetDate = new(2026, 9, 1, 0, 0, 0, TimeSpan.Zero);
private readonly IStatsService _statsService;
public StatsController(IStatsService statsService)
@@ -23,8 +20,7 @@ public class StatsController : ControllerBase
}
/// <summary>
/// Gets aggregated application statistics for the specified timeframe.
/// Deprecated. Use <c>GET /api/v2/stats</c> instead. Responses carry Deprecation/Link headers.
/// Gets aggregated application statistics for the specified timeframe
/// </summary>
/// <param name="hours">Timeframe in hours (default 24, range 1-720)</param>
/// <param name="includeEvents">Number of recent events to include (0 = none, max 100)</param>
@@ -35,17 +31,6 @@ public class StatsController : ControllerBase
[FromQuery] int includeEvents = 0,
[FromQuery] int includeStrikes = 0)
{
Response.Headers["Deprecation"] = "true";
Response.Headers["Sunset"] = SunsetDate.ToString("R");
Response.Headers["Link"] =
"</api/v2/stats>; rel=\"successor-version\", " +
"<https://cleanuparr.github.io/Cleanuparr/docs/configuration/stats>; rel=\"deprecation\"";
if (DateTimeOffset.UtcNow >= SunsetDate)
{
return NotFound();
}
hours = Math.Clamp(hours, 1, 720);
includeEvents = Math.Clamp(includeEvents, 0, 100);
includeStrikes = Math.Clamp(includeStrikes, 0, 100);
@@ -1,64 +0,0 @@
using Cleanuparr.Api.Common;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Stats;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Controllers;
[ApiController]
[Route("api/v2/stats")]
[Authorize]
public class StatsV2Controller : ControllerBase
{
private readonly IStatsService _statsService;
public StatsV2Controller(IStatsService statsService)
{
_statsService = statsService;
}
/// <summary>
/// Aggregated statistics for the given timeframe. Every section except health is scoped to the timeframe and, by
/// default, excludes dry-run activity.
/// </summary>
/// <param name="hours">Timeframe in hours (default 168, range 1-8760)</param>
/// <param name="includeDryRun">Include dry-run activity in the timeframe-scoped sections (default false)</param>
[HttpGet]
public async Task<IActionResult> GetStats([FromQuery] int hours = 168, [FromQuery] bool includeDryRun = false)
{
hours = TimelineWindow.ClampHours(hours);
StatsV2Response stats = await _statsService.GetStatsV2Async(hours, includeDryRun);
return Ok(stats);
}
/// <summary>
/// Bucketed timeline for a single metric.
/// </summary>
/// <param name="metric">strikesIssued | recovered | removed | malwareBlocked | events</param>
/// <param name="hours">Timeframe in hours (default 720, range 1-8760)</param>
/// <param name="bucket">Bucket size: hour | day | week | month. When omitted, hourly for timeframes up to 24h, daily otherwise.</param>
/// <param name="includeDryRun">Include dry-run activity (default false)</param>
[HttpGet("timeline")]
public async Task<IActionResult> GetTimeline(
[FromQuery] string metric = "events",
[FromQuery] int hours = 720,
[FromQuery] string? bucket = null,
[FromQuery] bool includeDryRun = false)
{
TimelineBucketSize? size = null;
if (!string.IsNullOrWhiteSpace(bucket))
{
if (!Enum.TryParse(bucket, ignoreCase: true, out TimelineBucketSize parsed) || !Enum.IsDefined(parsed))
{
return BadRequest($"Unsupported bucket '{bucket}'. Supported values: hour, day, week, month.");
}
size = parsed;
}
hours = TimelineWindow.ClampHours(hours);
List<TimelineBucketDto> series = await _statsService.GetTimelineAsync(metric, hours, size, includeDryRun);
return Ok(series);
}
}
@@ -1,5 +1,3 @@
using Cleanuparr.Api.Contracts.Responses;
using Cleanuparr.Api.Features.Strikes.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
@@ -172,3 +170,39 @@ public class StrikesController : ControllerBase
return NoContent();
}
}
public class DownloadItemStrikesDto
{
public Guid DownloadItemId { get; set; }
public string DownloadId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public int TotalStrikes { get; set; }
public Dictionary<string, int> StrikesByType { get; set; } = new();
public DateTimeOffset LatestStrikeAt { get; set; }
public DateTimeOffset FirstStrikeAt { get; set; }
public bool IsMarkedForRemoval { get; set; }
public bool IsRemoved { get; set; }
public bool IsReturning { get; set; }
public bool HasDryRunStrikes { get; set; }
public List<StrikeDetailDto> Strikes { get; set; } = [];
}
public class StrikeDetailDto
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public long? LastDownloadedBytes { get; set; }
public Guid JobRunId { get; set; }
public bool IsDryRun { get; set; }
}
public class RecentStrikeDto
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public string DownloadId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public bool IsDryRun { get; set; }
}
@@ -28,7 +28,6 @@ public sealed class AuthController : ControllerBase
private readonly IPlexAuthService _plexAuthService;
private readonly IOidcAuthService _oidcAuthService;
private readonly ILogger<AuthController> _logger;
private readonly IWebHostEnvironment _environment;
public AuthController(
UsersContext usersContext,
@@ -38,8 +37,7 @@ public sealed class AuthController : ControllerBase
ITotpService totpService,
IPlexAuthService plexAuthService,
IOidcAuthService oidcAuthService,
ILogger<AuthController> logger,
IWebHostEnvironment environment)
ILogger<AuthController> logger)
{
_usersContext = usersContext;
_dataContext = dataContext;
@@ -49,7 +47,6 @@ public sealed class AuthController : ControllerBase
_plexAuthService = plexAuthService;
_oidcAuthService = oidcAuthService;
_logger = logger;
_environment = environment;
}
[HttpGet("status")]
@@ -500,17 +497,7 @@ public sealed class AuthController : ControllerBase
return this.ProblemResult(StatusCodes.Status400BadRequest, "Plex login is not available");
}
string baseUrl = HttpContext.GetExternalBaseUrl();
if (_environment.IsDevelopment())
{
string origin = Request.Headers.Origin.ToString();
if (!string.IsNullOrEmpty(origin))
{
baseUrl = $"{origin}{Request.GetSafeBasePath()}";
}
}
string forwardUrl = $"{baseUrl}/auth/plex/callback";
PlexPinResult pin = await _plexAuthService.RequestPin(forwardUrl);
var pin = await _plexAuthService.RequestPin();
return Ok(new PlexPinStatusResponse
{
@@ -1,73 +0,0 @@
using System.Linq.Expressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Events;
namespace Cleanuparr.Api.Features.Events.Contracts.Responses;
public class EventListItem
{
public Guid Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public EventType EventType { get; set; }
public string Message { get; set; } = string.Empty;
public EventSeverity Severity { get; set; }
public Guid? TrackingId { get; set; }
public Guid? StrikeId { get; set; }
public Guid? JobRunId { get; set; }
public Guid? ArrInstanceId { get; set; }
public Guid? DownloadClientId { get; set; }
public SearchCommandStatus? SearchStatus { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public Guid? CycleId { get; set; }
public bool IsDryRun { get; set; }
public string? ItemTitle { get; set; }
public string? ItemHash { get; set; }
public int? StrikeCount { get; set; }
public List<string> FailedImportReasons { get; set; } = [];
public DeleteReason? DeleteReason { get; set; }
public bool? RemoveFromClient { get; set; }
public CleanReason? CleanReason { get; set; }
public string? CleanedCategory { get; set; }
public double? SeedRatio { get; set; }
public double? SeedingTimeHours { get; set; }
public string? OldCategory { get; set; }
public string? NewCategory { get; set; }
public bool? IsCategoryTag { get; set; }
public SeekerSearchType? SearchType { get; set; }
public SeekerSearchReason? SearchReason { get; set; }
public List<string> GrabbedItems { get; set; } = [];
public static readonly Expression<Func<AppEvent, EventListItem>> FromEvent = e => new EventListItem
{
Id = e.Id,
Timestamp = e.Timestamp,
EventType = e.EventType,
Message = e.Message,
Severity = e.Severity,
TrackingId = e.TrackingId,
StrikeId = e.StrikeId,
JobRunId = e.JobRunId,
ArrInstanceId = e.ArrInstanceId,
DownloadClientId = e.DownloadClientId,
SearchStatus = e.SearchStatus,
CompletedAt = e.CompletedAt,
CycleId = e.CycleId,
IsDryRun = e.IsDryRun,
ItemTitle = e.ItemTitle,
ItemHash = e.ItemHash,
StrikeCount = e.StrikeCount,
FailedImportReasons = e.FailedImportReasons,
DeleteReason = e.DeleteReason,
RemoveFromClient = e.RemoveFromClient,
CleanReason = e.CleanReason,
CleanedCategory = e.CleanedCategory,
SeedRatio = e.SeedRatio,
SeedingTimeHours = e.SeedingTimeHours,
OldCategory = e.OldCategory,
NewCategory = e.NewCategory,
IsCategoryTag = e.IsCategoryTag,
SearchType = e.SearchType,
SearchReason = e.SearchReason,
GrabbedItems = e.GrabbedItems,
};
}
@@ -1,8 +0,0 @@
namespace Cleanuparr.Api.Features.Events.Contracts.Responses;
public sealed record EventTypeTimelineBucket
{
public DateTimeOffset Date { get; init; }
public Dictionary<string, int> Counts { get; init; } = new();
}
@@ -1,8 +0,0 @@
namespace Cleanuparr.Api.Features.Events.Contracts.Responses;
public sealed record EventTypeTimelineResponse
{
public List<string> Types { get; init; } = [];
public List<EventTypeTimelineBucket> Buckets { get; init; } = [];
}
@@ -27,8 +27,6 @@ public sealed record UpdateGeneralConfigRequest
public ushort StrikeInactivityWindowHours { get; init; } = 24;
public ushort HistoryRetentionDays { get; init; } = 365;
public UpdateLoggingConfigRequest Log { get; init; } = new();
public UpdateAuthConfigRequest Auth { get; init; } = new();
@@ -44,7 +42,6 @@ public sealed record UpdateGeneralConfigRequest
existingConfig.EncryptionKey = EncryptionKey;
existingConfig.IgnoredDownloads = IgnoredDownloads;
existingConfig.StrikeInactivityWindowHours = StrikeInactivityWindowHours;
existingConfig.HistoryRetentionDays = HistoryRetentionDays;
bool loggingChanged = Log.ApplyTo(existingConfig.Log);
Auth.ApplyTo(existingConfig.Auth);
@@ -73,16 +70,6 @@ public sealed record UpdateGeneralConfigRequest
throw new ValidationException("STRIKE_INACTIVITY_WINDOW_HOURS must be less than or equal to 168");
}
if (config.HistoryRetentionDays is 0)
{
throw new ValidationException("HISTORY_RETENTION_DAYS must be greater than 0");
}
if (config.HistoryRetentionDays > 3650)
{
throw new ValidationException("HISTORY_RETENTION_DAYS must be less than or equal to 3650");
}
config.Log.Validate();
config.Auth.Validate();
}
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
namespace Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
@@ -8,6 +9,8 @@ public sealed record UpdateMalwareBlockerConfigRequest
{
public bool Enabled { get; init; }
public JobTriggerMode TriggerMode { get; init; } = JobTriggerMode.Schedule;
public string CronExpression { get; init; } = "0/5 * * * * ?";
public bool UseAdvancedScheduling { get; init; }
@@ -35,6 +38,7 @@ public sealed record UpdateMalwareBlockerConfigRequest
public ContentBlockerConfig ApplyTo(ContentBlockerConfig config)
{
config.Enabled = Enabled;
config.TriggerMode = TriggerMode;
config.CronExpression = CronExpression;
config.UseAdvancedScheduling = UseAdvancedScheduling;
config.IgnorePrivate = IgnorePrivate;
@@ -82,7 +82,11 @@ public sealed class MalwareBlockerConfigController : ControllerBase
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
{
if (config.Enabled)
// Webhook-only mode keeps the feature enabled but removes the cron trigger.
bool scheduleEnabled = config.Enabled &&
config is not ContentBlockerConfig { TriggerMode: JobTriggerMode.Webhook };
if (scheduleEnabled)
{
if (!string.IsNullOrEmpty(config.CronExpression))
{
@@ -164,6 +164,7 @@ public sealed class SearchStatsController : ControllerBase
var query = _eventsContext.Events
.AsNoTracking()
.Include(e => e.SearchEventData)
.Where(e => e.EventType == EventType.SearchTriggered);
// Filter by instance ID
@@ -178,12 +179,12 @@ public sealed class SearchStatsController : ControllerBase
query = query.Where(e => e.CycleId == cycleId.Value);
}
// Search by item title
// Search by item title in SearchEventData
if (!string.IsNullOrWhiteSpace(search))
{
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e => e.ItemTitle != null
&& EF.Functions.Like(e.ItemTitle, pattern));
query = query.Where(e => e.SearchEventData != null
&& EF.Functions.Like(e.SearchEventData.ItemTitle, pattern));
}
// Filter by search status (multi-valued)
@@ -196,13 +197,13 @@ public sealed class SearchStatsController : ControllerBase
if (searchType.HasValue)
{
SeekerSearchType typeValue = searchType.Value;
query = query.Where(e => e.SearchType == typeValue);
query = query.Where(e => e.SearchEventData != null && e.SearchEventData.SearchType == typeValue);
}
if (searchReason.HasValue)
{
SeekerSearchReason reasonValue = searchReason.Value;
query = query.Where(e => e.SearchReason == reasonValue);
query = query.Where(e => e.SearchEventData != null && e.SearchEventData.SearchReason == reasonValue);
}
// Filter by grabbed-result presence
@@ -210,11 +211,11 @@ public sealed class SearchStatsController : ControllerBase
{
if (grabbed.Value)
{
query = query.Where(e => e.GrabbedItems.Count > 0);
query = query.Where(e => e.SearchEventData != null && e.SearchEventData.GrabbedItems.Count > 0);
}
else
{
query = query.Where(e => e.GrabbedItems.Count == 0);
query = query.Where(e => e.SearchEventData == null || e.SearchEventData.GrabbedItems.Count == 0);
}
}
@@ -225,14 +226,14 @@ public sealed class SearchStatsController : ControllerBase
IOrderedQueryable<AppEvent> ordered = sortBy switch
{
SearchEventsSortBy.Title => ascending
? query.OrderBy(e => e.ItemTitle ?? string.Empty)
: query.OrderByDescending(e => e.ItemTitle ?? string.Empty),
? query.OrderBy(e => e.SearchEventData != null ? e.SearchEventData.ItemTitle : string.Empty)
: query.OrderByDescending(e => e.SearchEventData != null ? e.SearchEventData.ItemTitle : string.Empty),
SearchEventsSortBy.Status => ascending
? query.OrderBy(e => e.SearchStatus)
: query.OrderByDescending(e => e.SearchStatus),
SearchEventsSortBy.Type => ascending
? query.OrderBy(e => e.SearchType)
: query.OrderByDescending(e => e.SearchType),
? query.OrderBy(e => e.SearchEventData != null ? (int)e.SearchEventData.SearchType : 0)
: query.OrderByDescending(e => e.SearchEventData != null ? (int)e.SearchEventData.SearchType : 0),
_ => ascending
? query.OrderBy(e => e.Timestamp)
: query.OrderByDescending(e => e.Timestamp),
@@ -272,12 +273,12 @@ public sealed class SearchStatsController : ControllerBase
InstanceType = e.ArrInstanceId.HasValue && instanceTypeMap.TryGetValue(e.ArrInstanceId.Value, out var it)
? it.ToString()
: null,
ItemTitle = e.ItemTitle ?? "Unknown",
SearchType = e.SearchType ?? SeekerSearchType.Proactive,
SearchReason = e.SearchReason,
ItemTitle = e.SearchEventData?.ItemTitle ?? "Unknown",
SearchType = e.SearchEventData?.SearchType ?? SeekerSearchType.Proactive,
SearchReason = e.SearchEventData?.SearchReason,
SearchStatus = e.SearchStatus,
CompletedAt = e.CompletedAt,
GrabbedItems = e.GrabbedItems,
GrabbedItems = e.SearchEventData?.GrabbedItems ?? [],
CycleId = e.CycleId,
IsDryRun = e.IsDryRun,
}).ToList();
@@ -1,17 +0,0 @@
namespace Cleanuparr.Api.Features.Strikes.Contracts.Responses;
public class DownloadItemStrikesDto
{
public Guid DownloadItemId { get; set; }
public string DownloadId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public int TotalStrikes { get; set; }
public Dictionary<string, int> StrikesByType { get; set; } = new();
public DateTimeOffset LatestStrikeAt { get; set; }
public DateTimeOffset FirstStrikeAt { get; set; }
public bool IsMarkedForRemoval { get; set; }
public bool IsRemoved { get; set; }
public bool IsReturning { get; set; }
public bool HasDryRunStrikes { get; set; }
public List<StrikeDetailDto> Strikes { get; set; } = [];
}
@@ -1,11 +0,0 @@
namespace Cleanuparr.Api.Features.Strikes.Contracts.Responses;
public class RecentStrikeDto
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public string DownloadId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public bool IsDryRun { get; set; }
}
@@ -1,11 +0,0 @@
namespace Cleanuparr.Api.Features.Strikes.Contracts.Responses;
public class StrikeDetailDto
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public long? LastDownloadedBytes { get; set; }
public Guid JobRunId { get; set; }
public bool IsDryRun { get; set; }
}
@@ -0,0 +1,12 @@
namespace Cleanuparr.Api.Features.Webhooks.Contracts;
/// <summary>
/// The *arr webhook event types Cleanuparr acts on. Unrecognized events parse to
/// <see cref="Unknown"/> and are ignored.
/// </summary>
public enum ArrWebhookEventType
{
Unknown = 0,
Test,
Grab,
}
@@ -0,0 +1,25 @@
namespace Cleanuparr.Api.Features.Webhooks.Contracts;
/// <summary>
/// Minimal, tolerant projection of the Sonarr/Radarr "On Grab" Webhook payload. Only the fields used
/// to trigger a targeted MalwareBlocker scan are bound; all other fields are ignored.
/// </summary>
public sealed record ArrWebhookPayload
{
/// <summary>"Grab" to act on; "Test" is sent when the connection's Test button is clicked.</summary>
public string? EventType { get; init; }
/// <summary>Torrent infohash (or NZB id) identifying the download in the download client.</summary>
public string? DownloadId { get; init; }
/// <summary>Present on Sonarr payloads; carries the series content id.</summary>
public ArrWebhookContent? Series { get; init; }
/// <summary>Present on Radarr payloads; carries the movie content id.</summary>
public ArrWebhookContent? Movie { get; init; }
}
public sealed record ArrWebhookContent
{
public long Id { get; init; }
}
@@ -0,0 +1,109 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Webhooks.Contracts;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Webhooks.Controllers;
/// <summary>
/// Receives Sonarr/Radarr "On Grab" webhooks and triggers a targeted MalwareBlocker scan of the
/// grabbed download. Authentication reuses the account API key (e.g. <c>?apikey=</c>), so the URL can
/// be pasted directly into the *arr Webhook connection.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize]
public sealed class WebhooksController : ControllerBase
{
private readonly ILogger<WebhooksController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public WebhooksController(
ILogger<WebhooksController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpPost("malware-blocker/{instanceId:guid}")]
public async Task<IActionResult> TriggerMalwareBlocker(Guid instanceId, [FromBody] ArrWebhookPayload payload)
{
Enum.TryParse(payload.EventType, ignoreCase: true, out ArrWebhookEventType eventType);
// The Test button sends an event we acknowledge without doing any work.
if (eventType is ArrWebhookEventType.Test)
{
_logger.LogInformation("Received MalwareBlocker test webhook for instance {instanceId}", instanceId);
return Ok();
}
if (eventType is not ArrWebhookEventType.Grab)
{
_logger.LogDebug("Ignoring MalwareBlocker webhook event '{eventType}' for instance {instanceId}",
payload.EventType, instanceId);
return Ok();
}
ArrConfig? arrConfig;
ArrInstance? instance;
ContentBlockerConfig config;
await DataContext.Lock.WaitAsync();
try
{
arrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Instances.Any(i => i.Id == instanceId));
instance = arrConfig?.Instances.FirstOrDefault(i => i.Id == instanceId);
config = await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync();
}
finally
{
DataContext.Lock.Release();
}
if (arrConfig is null || instance is null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"No arr instance found with id {instanceId}");
}
if (arrConfig.Type is not (InstanceType.Sonarr or InstanceType.Radarr))
{
return this.ProblemResult(StatusCodes.Status422UnprocessableEntity, "MalwareBlocker webhooks are only supported for Sonarr and Radarr");
}
if (!config.Enabled || config.TriggerMode is JobTriggerMode.Schedule)
{
_logger.LogDebug("Ignoring MalwareBlocker webhook | webhook triggering is not enabled");
return Ok();
}
if (string.IsNullOrWhiteSpace(payload.DownloadId))
{
_logger.LogDebug("Ignoring MalwareBlocker webhook | no download id in payload (usenet or pre-grab)");
return Ok();
}
long contentId = arrConfig.Type switch
{
InstanceType.Sonarr => payload.Series?.Id ?? 0,
InstanceType.Radarr => payload.Movie?.Id ?? 0,
_ => 0,
};
await _jobManagementService.TriggerMalwareBlockerWebhook(instanceId, payload.DownloadId, contentId, arrConfig.Type);
return Ok();
}
}
@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.Jobs;
@@ -112,6 +113,7 @@ public class BackgroundJobManager : IHostedService
// Always register jobs, regardless of enabled status
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
await RegisterMalwareBlockerWebhookJob(cancellationToken);
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
await RegisterSeekerJob(seekerConfig, cancellationToken);
@@ -144,13 +146,24 @@ public class BackgroundJobManager : IHostedService
{
// Always register the job definition
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken);
// Only add triggers if the job is enabled
if (config.Enabled)
// Only add the cron trigger when scheduling is part of the trigger mode
if (config.Enabled && config.TriggerMode is not JobTriggerMode.Webhook)
{
await AddTriggersForJob<MalwareBlocker>(config.CronExpression, cancellationToken);
}
}
/// <summary>
/// Registers the webhook-triggered MalwareBlocker job under a dedicated JobKey (no cron trigger).
/// The dedicated key gives webhook runs their own DisallowConcurrentExecution lock, independent of
/// the scheduled MalwareBlocker job. Triggers are scheduled on demand when an "On Grab" webhook
/// is received.
/// </summary>
public async Task RegisterMalwareBlockerWebhookJob(CancellationToken cancellationToken = default)
{
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken, Constants.MalwareBlockerWebhookJobKey);
}
/// <summary>
/// Registers the DownloadCleaner job and optionally adds triggers based on configuration.
@@ -273,17 +286,17 @@ public class BackgroundJobManager : IHostedService
/// <summary>
/// Helper method to add a job without a trigger (for chained jobs).
/// </summary>
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default, string? jobKeyName = null)
where T : IHandler
{
if (_scheduler == null)
{
throw new InvalidOperationException("Scheduler not initialized");
}
string typeName = typeof(T).Name;
string typeName = jobKeyName ?? typeof(T).Name;
var jobKey = new JobKey(typeName);
// Check if job already exists
if (await _scheduler.CheckExists(jobKey, cancellationToken))
{
@@ -48,6 +48,8 @@ public sealed class GenericJob<T> : IJob
ContextProvider.SetJobRunId(jobRunId);
using var __ = LogContext.PushProperty(LogProperties.JobRunId, jobRunId.ToString());
SetWebhookScanTarget(context);
await BroadcastJobStatus(hubContext, jobManagementService, jobType, false);
var handler = scope.ServiceProvider.GetRequiredService<T>();
@@ -75,6 +77,34 @@ public sealed class GenericJob<T> : IJob
}
}
/// <summary>
/// When the firing trigger carries a webhook scan target in its JobDataMap, surfaces it to the
/// handler via the ContextProvider so the run scans only that download. No-op for normal triggers.
/// </summary>
private static void SetWebhookScanTarget(IJobExecutionContext context)
{
JobDataMap dataMap = context.MergedJobDataMap;
if (!dataMap.ContainsKey(WebhookScanTarget.InstanceIdKey))
{
return;
}
if (!Guid.TryParse(dataMap.GetString(WebhookScanTarget.InstanceIdKey), out Guid instanceId) ||
!Enum.TryParse(dataMap.GetString(WebhookScanTarget.InstanceTypeKey), out InstanceType instanceType))
{
return;
}
string downloadId = dataMap.GetString(WebhookScanTarget.DownloadIdKey) ?? string.Empty;
long contentId = dataMap.GetLong(WebhookScanTarget.ContentIdKey);
int retryIndex = dataMap.ContainsKey(WebhookScanTarget.RetryIndexKey)
? dataMap.GetInt(WebhookScanTarget.RetryIndexKey)
: 0;
ContextProvider.Set(new WebhookScanTarget(instanceId, downloadId, contentId, instanceType, retryIndex));
}
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, JobType jobType, bool isFinished)
{
try
@@ -13,5 +13,4 @@ public enum EventType
CategoryChanged,
DownloadMarkedForDeletion,
SearchTriggered,
StrikeReset,
}
@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Enums;
public enum JobTriggerMode
{
Schedule,
Webhook,
Both,
}
@@ -1,7 +0,0 @@
namespace Cleanuparr.Domain.Enums;
public enum ManualEventType
{
RecurringDownload,
SearchNotTriggered,
}
@@ -1,9 +0,0 @@
namespace Cleanuparr.Domain.Enums;
public enum TimelineBucketSize
{
Hour,
Day,
Week,
Month,
}
@@ -1,153 +0,0 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Cleanuparr.Persistence.Models.State;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Events;
/// <summary>
/// Exercises the EventCleanupService prune logic against a real SQLite context
/// (the InMemory provider cannot run ExecuteDeleteAsync).
/// </summary>
public class EventCleanupLogicTests : IDisposable
{
private readonly EventsContext _context;
private readonly EventCleanupService _service;
public EventCleanupLogicTests()
{
_context = TestEventsContextFactory.Create();
_service = new EventCleanupService(
Substitute.For<ILogger<EventCleanupService>>(),
Substitute.For<IServiceScopeFactory>());
}
public void Dispose()
{
_context.Dispose();
GC.SuppressFinalize(this);
}
[Fact]
public async Task PruneEventsAsync_DeletesEventsBeyondRetention()
{
_context.Events.Add(new AppEvent
{
EventType = EventType.StrikeReset,
Message = "stale",
Severity = EventSeverity.Information,
Timestamp = DateTimeOffset.UtcNow.AddDays(-400),
});
_context.Events.Add(new AppEvent
{
EventType = EventType.StrikeReset,
Message = "fresh",
Severity = EventSeverity.Information,
Timestamp = DateTimeOffset.UtcNow.AddDays(-10),
});
await _context.SaveChangesAsync();
await _service.PruneEventsAsync(_context, retentionDays: 365);
List<AppEvent> remaining = await _context.Events.ToListAsync();
remaining.Count.ShouldBe(1);
remaining[0].Message.ShouldBe("fresh");
}
[Fact]
public async Task DeleteResolvedManualEventsAsync_KeepsRecentlyResolvedOldEvents()
{
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-30);
// Created long ago but resolved just now — must survive so the publish cooldown still sees it.
ManualEvent freshlyResolved = new()
{
Type = ManualEventType.RecurringDownload,
Message = "fresh",
Severity = EventSeverity.Warning,
Timestamp = DateTimeOffset.UtcNow.AddDays(-40),
IsResolved = true,
ResolvedAt = DateTimeOffset.UtcNow,
};
// Created and resolved long ago — safe to delete.
ManualEvent longResolved = new()
{
Type = ManualEventType.SearchNotTriggered,
Message = "stale",
Severity = EventSeverity.Warning,
Timestamp = DateTimeOffset.UtcNow.AddDays(-40),
IsResolved = true,
ResolvedAt = DateTimeOffset.UtcNow.AddDays(-35),
};
// Old but still unresolved — never deleted here.
ManualEvent unresolved = new()
{
Type = ManualEventType.RecurringDownload,
Message = "open",
Severity = EventSeverity.Warning,
Timestamp = DateTimeOffset.UtcNow.AddDays(-40),
IsResolved = false,
};
_context.ManualEvents.AddRange(freshlyResolved, longResolved, unresolved);
await _context.SaveChangesAsync();
await _service.DeleteResolvedManualEventsAsync(_context, cutoff);
List<string> remaining = await _context.ManualEvents.Select(e => e.Message).ToListAsync();
remaining.ShouldContain("fresh");
remaining.ShouldContain("open");
remaining.ShouldNotContain("stale");
}
[Fact]
public async Task PruneJobRunsAsync_DeletesOnlyOldCompletedUnreferencedRuns()
{
DateTimeOffset oldTime = DateTimeOffset.UtcNow.AddDays(-40);
DateTimeOffset recentTime = DateTimeOffset.UtcNow.AddDays(-5);
JobRun unreferenced = new() { Id = Guid.NewGuid(), Type = JobType.QueueCleaner, StartedAt = oldTime, CompletedAt = oldTime };
JobRun referencedByStrike = new() { Id = Guid.NewGuid(), Type = JobType.QueueCleaner, StartedAt = oldTime, CompletedAt = oldTime };
JobRun referencedByEvent = new() { Id = Guid.NewGuid(), Type = JobType.QueueCleaner, StartedAt = oldTime, CompletedAt = oldTime };
JobRun referencedByManualEvent = new() { Id = Guid.NewGuid(), Type = JobType.QueueCleaner, StartedAt = oldTime, CompletedAt = oldTime };
JobRun recent = new() { Id = Guid.NewGuid(), Type = JobType.QueueCleaner, StartedAt = recentTime, CompletedAt = recentTime };
JobRun incomplete = new() { Id = Guid.NewGuid(), Type = JobType.QueueCleaner, StartedAt = oldTime, CompletedAt = null };
_context.JobRuns.AddRange(unreferenced, referencedByStrike, referencedByEvent, referencedByManualEvent, recent, incomplete);
DownloadItem item = new() { DownloadId = "h1", Title = "t1" };
_context.DownloadItems.Add(item);
_context.Strikes.Add(new Strike { DownloadItemId = item.Id, JobRunId = referencedByStrike.Id, Type = StrikeType.Stalled });
_context.Events.Add(new AppEvent
{
EventType = EventType.StalledStrike,
Message = "e",
Severity = EventSeverity.Important,
JobRunId = referencedByEvent.Id,
});
_context.ManualEvents.Add(new ManualEvent
{
Type = ManualEventType.RecurringDownload,
Message = "m",
Severity = EventSeverity.Important,
JobRunId = referencedByManualEvent.Id,
});
await _context.SaveChangesAsync();
await _service.PruneJobRunsAsync(_context, DateTimeOffset.UtcNow.AddDays(-30));
List<Guid> remaining = await _context.JobRuns.Select(j => j.Id).ToListAsync();
remaining.ShouldNotContain(unreferenced.Id);
remaining.ShouldContain(referencedByStrike.Id);
remaining.ShouldContain(referencedByEvent.Id);
remaining.ShouldContain(referencedByManualEvent.Id);
remaining.ShouldContain(recent.Id);
remaining.ShouldContain(incomplete.Id);
}
}
@@ -88,25 +88,23 @@ public class EventPublisherTests : IDisposable
}
[Fact]
public async Task PublishAsync_WithConfigure_PersistsTypedFields()
public async Task PublishAsync_WithData_SerializesDataToJson()
{
// Arrange
var eventType = EventType.DownloadCleaned;
var message = "Download cleaned";
var severity = EventSeverity.Information;
var data = new { Name = "TestDownload", Hash = "abc123" };
// Act
await _publisher.PublishAsync(eventType, message, severity, configure: e =>
{
e.ItemTitle = "TestDownload";
e.ItemHash = "abc123";
});
await _publisher.PublishAsync(eventType, message, severity, data);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
savedEvent.ShouldNotBeNull();
savedEvent.ItemTitle.ShouldBe("TestDownload");
savedEvent.ItemHash.ShouldBe("abc123");
savedEvent.Data.ShouldNotBeNull();
savedEvent.Data.ShouldContain("TestDownload");
savedEvent.Data.ShouldContain("abc123");
}
[Fact]
@@ -168,7 +166,7 @@ public class EventPublisherTests : IDisposable
}
[Fact]
public async Task PublishAsync_NullConfigure_LeavesTypedFieldsUnset()
public async Task PublishAsync_NullData_DoesNotSerialize()
{
// Arrange
var eventType = EventType.DownloadCleaned;
@@ -176,13 +174,12 @@ public class EventPublisherTests : IDisposable
var severity = EventSeverity.Information;
// Act
await _publisher.PublishAsync(eventType, message, severity, configure: null);
await _publisher.PublishAsync(eventType, message, severity, data: null);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
savedEvent.ShouldNotBeNull();
savedEvent.ItemTitle.ShouldBeNull();
savedEvent.ItemHash.ShouldBeNull();
savedEvent.Data.ShouldBeNull();
}
#endregion
@@ -197,7 +194,7 @@ public class EventPublisherTests : IDisposable
var severity = EventSeverity.Warning;
// Act
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, message, severity);
await _publisher.PublishManualAsync(message, severity);
// Assert
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
@@ -207,24 +204,22 @@ public class EventPublisherTests : IDisposable
}
[Fact]
public async Task PublishManualAsync_WithConfigure_PersistsTypedFields()
public async Task PublishManualAsync_WithData_SerializesDataToJson()
{
// Arrange
var message = "Manual event";
var severity = EventSeverity.Important;
var data = new { ItemName = "TestItem", Count = 5 };
// Act
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, message, severity, configure: e =>
{
e.ItemTitle = "TestItem";
e.StrikeCount = 5;
});
await _publisher.PublishManualAsync(message, severity, data);
// Assert
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
savedEvent.ShouldNotBeNull();
savedEvent.ItemTitle.ShouldBe("TestItem");
savedEvent.StrikeCount.ShouldBe(5);
savedEvent.Data.ShouldNotBeNull();
savedEvent.Data.ShouldContain("TestItem");
savedEvent.Data.ShouldContain("5");
}
[Fact]
@@ -235,7 +230,7 @@ public class EventPublisherTests : IDisposable
var severity = EventSeverity.Information;
// Act
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, message, severity);
await _publisher.PublishManualAsync(message, severity);
// Assert
await _clientProxy.Received(1).SendCoreAsync(
@@ -246,119 +241,6 @@ public class EventPublisherTests : IDisposable
#endregion
#region Manual Event Gating Tests
private async Task SeedManualEventAsync(ManualEventType type, string itemHash, bool isResolved, DateTimeOffset timestamp)
{
ManualEvent seed = new()
{
Type = type,
Message = "seed",
Severity = EventSeverity.Warning,
ItemHash = itemHash,
IsResolved = isResolved,
Timestamp = timestamp,
// For resolved seeds the timestamp represents when it was resolved (drives the cooldown).
ResolvedAt = isResolved ? timestamp : null,
};
_context.ManualEvents.Add(seed);
await _context.SaveChangesAsync();
}
[Fact]
public async Task PublishManualAsync_NoExistingEvent_CreatesEvent()
{
// Act
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, "msg", EventSeverity.Warning,
configure: e => e.ItemHash = "abc123");
// Assert
(await _context.ManualEvents.CountAsync()).ShouldBe(1);
}
[Fact]
public async Task PublishManualAsync_UnresolvedSameTypeAndHash_IsSkipped()
{
// Arrange
await SeedManualEventAsync(ManualEventType.RecurringDownload, "abc123", isResolved: false, DateTimeOffset.UtcNow.AddHours(-5));
// Act
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, "msg", EventSeverity.Warning,
configure: e => e.ItemHash = "abc123");
// Assert
(await _context.ManualEvents.CountAsync()).ShouldBe(1);
}
[Fact]
public async Task PublishManualAsync_ResolvedSameTypeAndHash_WithinCooldown_IsSkipped()
{
// Arrange - resolved 30 minutes ago (inside the 1h cooldown)
await SeedManualEventAsync(ManualEventType.RecurringDownload, "abc123", isResolved: true, DateTimeOffset.UtcNow.AddMinutes(-30));
// Act
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, "msg", EventSeverity.Warning,
configure: e => e.ItemHash = "abc123");
// Assert
(await _context.ManualEvents.CountAsync()).ShouldBe(1);
}
[Fact]
public async Task PublishManualAsync_ResolvedSameTypeAndHash_AfterCooldown_CreatesEvent()
{
// Arrange - resolved 2 hours ago (outside the 1h cooldown)
await SeedManualEventAsync(ManualEventType.RecurringDownload, "abc123", isResolved: true, DateTimeOffset.UtcNow.AddHours(-2));
// Act
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, "msg", EventSeverity.Warning,
configure: e => e.ItemHash = "abc123");
// Assert
(await _context.ManualEvents.CountAsync()).ShouldBe(2);
}
[Fact]
public async Task PublishManualAsync_SameHashDifferentType_CreatesEvent()
{
// Arrange
await SeedManualEventAsync(ManualEventType.RecurringDownload, "abc123", isResolved: false, DateTimeOffset.UtcNow.AddMinutes(-5));
// Act
await _publisher.PublishManualAsync(ManualEventType.SearchNotTriggered, "msg", EventSeverity.Warning,
configure: e => e.ItemHash = "abc123");
// Assert
(await _context.ManualEvents.CountAsync()).ShouldBe(2);
}
[Fact]
public async Task PublishManualAsync_NullHash_AlwaysCreates()
{
// Act
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, "msg", EventSeverity.Warning);
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, "msg", EventSeverity.Warning);
// Assert - no gate applies without an item hash
(await _context.ManualEvents.CountAsync()).ShouldBe(2);
}
[Fact]
public async Task PublishManualAsync_HashDifferingOnlyInCase_IsTreatedAsSameItem()
{
// Arrange - stored normalized (lowercase)
await SeedManualEventAsync(ManualEventType.RecurringDownload, "abc123", isResolved: false, DateTimeOffset.UtcNow.AddMinutes(-5));
// Act - publish with the same hash in a different case
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, "msg", EventSeverity.Warning,
configure: e => e.ItemHash = "ABC123");
// Assert
(await _context.ManualEvents.CountAsync()).ShouldBe(1);
}
#endregion
#region DryRun Tests
[Fact]
@@ -384,7 +266,7 @@ public class EventPublisherTests : IDisposable
var severity = EventSeverity.Important;
// Act
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, message, severity);
await _publisher.PublishManualAsync(message, severity);
// Assert
await _dryRunInterceptor.Received(1).IsDryRunEnabled();
@@ -434,7 +316,7 @@ public class EventPublisherTests : IDisposable
var severity = EventSeverity.Important;
// Act
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, message, severity);
await _publisher.PublishManualAsync(message, severity);
// Assert
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
@@ -462,6 +344,54 @@ public class EventPublisherTests : IDisposable
#endregion
#region Data Serialization Tests
[Fact]
public async Task PublishAsync_SerializesEnumsAsStrings()
{
// Arrange
var eventType = EventType.QueueItemDeleted;
var message = "Test";
var severity = EventSeverity.Important;
var data = new { Reason = DeleteReason.Stalled };
// Act
await _publisher.PublishAsync(eventType, message, severity, data);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
savedEvent.ShouldNotBeNull();
savedEvent.Data.ShouldNotBeNull();
savedEvent.Data.ShouldContain("Stalled");
}
[Fact]
public async Task PublishAsync_HandlesComplexData()
{
// Arrange
var eventType = EventType.DownloadCleaned;
var message = "Test";
var severity = EventSeverity.Information;
var data = new
{
Items = new[] { "item1", "item2" },
Nested = new { Value = 123 },
NullableValue = (string?)null
};
// Act
await _publisher.PublishAsync(eventType, message, severity, data);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
savedEvent.ShouldNotBeNull();
savedEvent.Data.ShouldNotBeNull();
savedEvent.Data.ShouldContain("item1");
savedEvent.Data.ShouldContain("123");
}
#endregion
#region PublishQueueItemDeleted Tests
[Fact]
@@ -479,25 +409,10 @@ public class EventPublisherTests : IDisposable
savedEvent.ShouldNotBeNull();
savedEvent.EventType.ShouldBe(EventType.QueueItemDeleted);
savedEvent.Severity.ShouldBe(EventSeverity.Important);
savedEvent.ItemTitle.ShouldBe("Test Download");
savedEvent.ItemHash.ShouldBe("abc123");
savedEvent.DeleteReason.ShouldBe(DeleteReason.Stalled);
}
[Fact]
public async Task PublishQueueItemDeleted_WithAllFilesBlocked_SetsDeleteReason()
{
// Arrange
ContextProvider.Set(ContextProvider.Keys.ItemName, "Malware Download");
ContextProvider.Set(ContextProvider.Keys.Hash, "mal123");
// Act
await _publisher.PublishQueueItemDeleted(removeFromClient: true, DeleteReason.AllFilesBlocked);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
savedEvent.ShouldNotBeNull();
savedEvent.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
savedEvent.Data.ShouldNotBeNull();
savedEvent.Data.ShouldContain("Test Download");
savedEvent.Data.ShouldContain("abc123");
savedEvent.Data.ShouldContain("Stalled");
}
[Fact]
@@ -537,12 +452,11 @@ public class EventPublisherTests : IDisposable
savedEvent.ShouldNotBeNull();
savedEvent.EventType.ShouldBe(EventType.DownloadCleaned);
savedEvent.Severity.ShouldBe(EventSeverity.Important);
savedEvent.ItemTitle.ShouldBe("Cleaned Download");
savedEvent.ItemHash.ShouldBe("def456");
savedEvent.CleanedCategory.ShouldBe("movies");
savedEvent.SeedRatio.ShouldBe(2.5);
savedEvent.SeedingTimeHours.ShouldBe(48.0);
savedEvent.CleanReason.ShouldBe(CleanReason.MaxSeedTimeReached);
savedEvent.Data.ShouldNotBeNull();
savedEvent.Data.ShouldContain("Cleaned Download");
savedEvent.Data.ShouldContain("def456");
savedEvent.Data.ShouldContain("movies");
savedEvent.Data.ShouldContain("MaxSeedTimeReached");
}
[Fact]
@@ -583,8 +497,9 @@ public class EventPublisherTests : IDisposable
savedEvent.ShouldNotBeNull();
savedEvent.Severity.ShouldBe(EventSeverity.Warning);
savedEvent.Message.ShouldContain("Replacement search was not triggered");
savedEvent.ItemTitle.ShouldBe("Test Item");
savedEvent.ItemHash.ShouldBe("abc123");
savedEvent.Data.ShouldNotBeNull();
savedEvent.Data.ShouldContain("Test Item");
savedEvent.Data.ShouldContain("abc123");
}
#endregion
@@ -606,9 +521,9 @@ public class EventPublisherTests : IDisposable
savedEvent.ShouldNotBeNull();
savedEvent.Severity.ShouldBe(EventSeverity.Important);
savedEvent.Message.ShouldContain("keeps coming back");
savedEvent.ItemTitle.ShouldBe("Recurring Item");
savedEvent.ItemHash.ShouldBe("hash123");
savedEvent.StrikeCount.ShouldBe(5);
savedEvent.Data.ShouldNotBeNull();
savedEvent.Data.ShouldContain("Recurring Item");
savedEvent.Data.ShouldContain("hash123");
}
#endregion
@@ -720,7 +635,7 @@ public class EventPublisherTests : IDisposable
}
[Fact]
public async Task PublishSearchTriggered_SetsSearchFields()
public async Task PublishSearchTriggered_CreatesSearchEventData()
{
// Act
await _publisher.PublishSearchTriggered("Series A", SeekerSearchType.Replacement, SeekerSearchReason.Replacement);
@@ -728,9 +643,12 @@ public class EventPublisherTests : IDisposable
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
savedEvent.ShouldNotBeNull();
savedEvent.ItemTitle.ShouldBe("Series A");
savedEvent.SearchType.ShouldBe(SeekerSearchType.Replacement);
savedEvent.SearchReason.ShouldBe(SeekerSearchReason.Replacement);
var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == savedEvent.Id);
searchData.ShouldNotBeNull();
searchData.ItemTitle.ShouldBe("Series A");
searchData.SearchType.ShouldBe(SeekerSearchType.Replacement);
searchData.SearchReason.ShouldBe(SeekerSearchReason.Replacement);
}
[Fact]
@@ -804,7 +722,7 @@ public class EventPublisherTests : IDisposable
}
[Fact]
public async Task PublishSearchCompleted_UpdatesGrabbedItems()
public async Task PublishSearchCompleted_UpdatesGrabbedItemsOnSearchEventData()
{
// Arrange
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
@@ -815,13 +733,13 @@ public class EventPublisherTests : IDisposable
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, InstanceType.Radarr, "http://localhost:7878", grabbedItems);
// Assert
var updatedEvent = await _context.Events.FindAsync(eventId);
updatedEvent.ShouldNotBeNull();
updatedEvent.GrabbedItems.ShouldContain("Movie A (2024)");
var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == eventId);
searchData.ShouldNotBeNull();
searchData.GrabbedItems.ShouldContain("Movie A (2024)");
}
[Fact]
public async Task PublishSearchCompleted_WithNullGrabbedItems_LeavesGrabbedItemsEmpty()
public async Task PublishSearchCompleted_WithNullGrabbedItems_DoesNotModifySearchEventData()
{
// Arrange
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
@@ -830,9 +748,9 @@ public class EventPublisherTests : IDisposable
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, InstanceType.Radarr, "http://localhost:7878");
// Assert
var updatedEvent = await _context.Events.FindAsync(eventId);
updatedEvent.ShouldNotBeNull();
updatedEvent.GrabbedItems.ShouldBeEmpty();
var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == eventId);
searchData.ShouldNotBeNull();
searchData.GrabbedItems.ShouldBeEmpty();
}
[Fact]
@@ -847,40 +847,4 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
.SetTorrentLabel("hash1", "unlinked");
}
}
public class GetClaimedPaths_Tests : DelugeServiceDCTests
{
public GetClaimedPaths_Tests(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task DerivesRootFromFetchedFiles_SharedFolderDedupes()
{
var sut = _fixture.CreateSut();
var wrapper = new DelugeItemWrapper(new DownloadStatus
{
Hash = "hash1",
Name = "Renamed Display",
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
});
_fixture.ClientWrapper
.GetTorrentFiles("hash1")
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "show/file1.mkv" } },
{ "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1, Path = "show/file2.mkv" } }
}
});
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
claimed.ShouldContain("/downloads/show");
claimed.Count(p => p == "/downloads/show").ShouldBe(1);
claimed.ShouldNotContain("/downloads/Renamed Display");
}
}
}
@@ -1343,71 +1343,4 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
.AddTorrentTagAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), "unlinked");
}
}
public class GetClaimedPaths_Tests : QBitServiceDCTests
{
public GetClaimedPaths_Tests(QBitServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task UsesFileList_WhenDisplayNameDivergesFromDisk()
{
var sut = _fixture.CreateSut();
var wrapper = new QBitItemWrapper(
new TorrentInfo { Hash = "hash1", Name = "Renamed Display Name", SavePath = "/downloads" },
Array.Empty<TorrentTracker>(),
false);
_fixture.ClientWrapper
.GetTorrentContentsAsync("hash1")
.Returns(new[] { new TorrentContent { Index = 0, Name = "actual-folder/data.bin", Priority = TorrentContentPriority.Normal } });
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
claimed.ShouldContain("/downloads/actual-folder");
claimed.ShouldNotContain("/downloads/Renamed Display Name");
}
[Fact]
public async Task FallsBackToSavePathAndName_WhenFileListUnavailable()
{
// no files returned (e.g. metadata not yet fetched) — claim save path + name.
var sut = _fixture.CreateSut();
var wrapper = new QBitItemWrapper(
new TorrentInfo { Hash = "hash1", Name = "some-show", SavePath = "/downloads" },
Array.Empty<TorrentTracker>(),
false);
_fixture.ClientWrapper
.GetTorrentContentsAsync("hash1")
.Returns(Array.Empty<TorrentContent>());
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
claimed.ShouldContain("/downloads/some-show");
}
[Fact]
public async Task MultiFileSharingFolder_ClaimsSingleRoot()
{
// both files live under one folder → one claimed entry, not the deep file paths.
var sut = _fixture.CreateSut();
var wrapper = new QBitItemWrapper(
new TorrentInfo { Hash = "hash1", Name = "show", SavePath = "/downloads" },
Array.Empty<TorrentTracker>(),
false);
_fixture.ClientWrapper
.GetTorrentContentsAsync("hash1")
.Returns(new[]
{
new TorrentContent { Index = 0, Name = "show/file1.mkv", Priority = TorrentContentPriority.Normal },
new TorrentContent { Index = 1, Name = "show/file2.mkv", Priority = TorrentContentPriority.Normal }
});
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
claimed.ShouldContain("/downloads/show");
claimed.Count(p => p == "/downloads/show").ShouldBe(1);
claimed.ShouldNotContain("/downloads/show/file1.mkv");
}
}
}
@@ -772,32 +772,4 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
wrapper.Category.ShouldBe("unlinked");
}
}
public class GetClaimedPaths_Tests : RTorrentServiceDCTests
{
public GetClaimedPaths_Tests(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task ClaimsBasePathAndDirectory()
{
// rTorrent resolves base_path (content root) and directory (its parent) itself;
// no file lookup, and the display name is never involved.
var sut = _fixture.CreateSut();
var wrapper = new RTorrentItemWrapper(new RTorrentTorrent
{
Hash = "HASH1",
Name = "Renamed Display",
BasePath = "/downloads/show",
Directory = "/downloads"
});
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
claimed.ShouldContain("/downloads/show");
claimed.ShouldContain("/downloads");
claimed.ShouldNotContain("/downloads/Renamed Display");
}
}
}
@@ -1001,36 +1001,4 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
.TorrentSetLocationAsync(Arg.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true);
}
}
public class GetClaimedPaths_Tests : TransmissionServiceDCTests
{
public GetClaimedPaths_Tests(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task DerivesRootFromFileList_SharedFolderDedupes()
{
// Transmission carries the files in the list response; the root is derived from them,
// not the display name.
var sut = _fixture.CreateSut();
var wrapper = new TransmissionItemWrapper(new TorrentInfo
{
HashString = "hash1",
Name = "Renamed Display",
DownloadDir = "/downloads",
Files = new[]
{
new TransmissionTorrentFiles { Name = "show/file1.mkv" },
new TransmissionTorrentFiles { Name = "show/file2.mkv" }
}
});
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
claimed.ShouldContain("/downloads/show");
claimed.Count(p => p == "/downloads/show").ShouldBe(1);
claimed.ShouldNotContain("/downloads/Renamed Display");
}
}
}
@@ -708,33 +708,4 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
await _fixture.ClientWrapper.Received(1).SetTorrentLabelAsync("hash1", "unlinked");
}
}
public class GetClaimedPaths_Tests : UTorrentServiceDCTests
{
public GetClaimedPaths_Tests(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task DerivesRootFromFetchedFiles_SharedFolderDedupes()
{
var sut = _fixture.CreateSut();
var wrapper = new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Renamed Display", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
_fixture.ClientWrapper
.GetTorrentFilesAsync("hash1")
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "show/file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 1000 },
new UTorrentFile { Name = "show/file2.mkv", Priority = 1, Index = 1, Size = 1000, Downloaded = 1000 }
});
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
claimed.ShouldContain("/downloads/show");
claimed.Count(p => p == "/downloads/show").ShouldBe(1);
claimed.ShouldNotContain("/downloads/Renamed Display");
}
}
}
@@ -82,33 +82,10 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable
svc.LoginAsync().Returns(Task.CompletedTask);
svc.GetSeedingDownloads().Returns([]);
svc.GetAllTorrentsLite().Returns(torrents);
svc.GetClaimedPathsAsync(Arg.Any<IReadOnlyList<ITorrentItemWrapper>>())
.Returns(ci => Task.FromResult(BuildDefaultClaimedPaths(ci.Arg<IReadOnlyList<ITorrentItemWrapper>>())));
_fixture.DownloadServiceFactory.GetDownloadService(clientConfig).Returns(svc);
return svc;
}
private static IReadOnlyList<string> BuildDefaultClaimedPaths(IReadOnlyList<ITorrentItemWrapper> torrents)
{
HashSet<string> paths = new(StringComparer.OrdinalIgnoreCase);
foreach (ITorrentItemWrapper torrent in torrents)
{
if (string.IsNullOrEmpty(torrent.SavePath))
{
continue;
}
paths.Add(torrent.SavePath.TrimEnd(Path.DirectorySeparatorChar));
if (!string.IsNullOrEmpty(torrent.Name))
{
paths.Add(Path.Combine(torrent.SavePath, torrent.Name).TrimEnd(Path.DirectorySeparatorChar));
}
}
return paths.ToList();
}
[Fact]
public async Task OrphanedFiles_NoEnabledClientConfigs_SkipsScan()
{
@@ -1,3 +1,4 @@
using System.Text.Json;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
@@ -239,12 +240,16 @@ public class DownloadCleanerIntegrationTests : IDisposable
cleanedEvent.SearchStatus.ShouldBeNull();
cleanedEvent.CompletedAt.ShouldBeNull();
cleanedEvent.CycleId.ShouldBeNull();
cleanedEvent.ItemTitle.ShouldBe("Completed.Movie.2024");
cleanedEvent.ItemHash.ShouldBe("cleaned_hash_abc");
cleanedEvent.CleanedCategory.ShouldBe("completed");
cleanedEvent.SeedRatio.ShouldBe(1.5);
cleanedEvent.SeedingTimeHours.ShouldBe(24.0);
cleanedEvent.CleanReason.ShouldBe(CleanReason.MaxRatioReached);
cleanedEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(cleanedEvent.Data!))
{
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Completed.Movie.2024");
data.RootElement.GetProperty("hash").GetString().ShouldBe("cleaned_hash_abc");
data.RootElement.GetProperty("categoryName").GetString().ShouldBe("completed");
data.RootElement.GetProperty("ratio").GetDouble().ShouldBe(1.5);
data.RootElement.GetProperty("seedingTime").GetDouble().ShouldBe(24.0);
data.RootElement.GetProperty("reason").GetString().ShouldBe("MaxRatioReached");
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1)
@@ -318,11 +323,15 @@ public class DownloadCleanerIntegrationTests : IDisposable
categoryEvent.SearchStatus.ShouldBeNull();
categoryEvent.CompletedAt.ShouldBeNull();
categoryEvent.CycleId.ShouldBeNull();
categoryEvent.ItemTitle.ShouldBe("NoLinks.Movie.2024");
categoryEvent.ItemHash.ShouldBe("unlinked_hash_xyz");
categoryEvent.OldCategory.ShouldBe("completed");
categoryEvent.NewCategory.ShouldBe("unlinked");
categoryEvent.IsCategoryTag.ShouldBe(false);
categoryEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(categoryEvent.Data!))
{
data.RootElement.GetProperty("itemName").GetString().ShouldBe("NoLinks.Movie.2024");
data.RootElement.GetProperty("hash").GetString().ShouldBe("unlinked_hash_xyz");
data.RootElement.GetProperty("oldCategory").GetString().ShouldBe("completed");
data.RootElement.GetProperty("newCategory").GetString().ShouldBe("unlinked");
data.RootElement.GetProperty("isTag").GetBoolean().ShouldBe(false);
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1)
@@ -1,3 +1,4 @@
using System.Text.Json;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -43,7 +44,8 @@ public class MalwareBlockerIntegrationTests : IDisposable
_fixture.ArrQueueIterator,
_fixture.DownloadServiceFactory,
_fixture.BlocklistProvider,
_fixture.EventPublisher);
_fixture.EventPublisher,
Substitute.For<Cleanuparr.Infrastructure.Services.Interfaces.IJobManagementService>());
}
[Fact]
@@ -69,6 +71,7 @@ public class MalwareBlockerIntegrationTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
DeleteReason = DeleteReason.AllFilesBlocked,
IsPrivate = false
@@ -117,8 +120,12 @@ public class MalwareBlockerIntegrationTests : IDisposable
markedEvent.SearchStatus.ShouldBeNull();
markedEvent.CompletedAt.ShouldBeNull();
markedEvent.CycleId.ShouldBeNull();
markedEvent.ItemTitle.ShouldBe("Suspicious.Movie.2024.1080p");
markedEvent.ItemHash.ShouldBe("MALWARE_HASH_789");
markedEvent.Data.ShouldNotBeNull();
using (var markedData = JsonDocument.Parse(markedEvent.Data!))
{
markedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Suspicious.Movie.2024.1080p");
markedData.RootElement.GetProperty("hash").GetString().ShouldBe("MALWARE_HASH_789");
}
// QueueItemDeleted event
var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted);
@@ -133,10 +140,14 @@ public class MalwareBlockerIntegrationTests : IDisposable
deletedEvent.SearchStatus.ShouldBeNull();
deletedEvent.CompletedAt.ShouldBeNull();
deletedEvent.CycleId.ShouldBeNull();
deletedEvent.ItemTitle.ShouldBe("Suspicious.Movie.2024.1080p");
deletedEvent.ItemHash.ShouldBe("MALWARE_HASH_789");
deletedEvent.RemoveFromClient.ShouldBe(true);
deletedEvent.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
deletedEvent.Data.ShouldNotBeNull();
using (var deletedData = JsonDocument.Parse(deletedEvent.Data!))
{
deletedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Suspicious.Movie.2024.1080p");
deletedData.RootElement.GetProperty("hash").GetString().ShouldBe("MALWARE_HASH_789");
deletedData.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(true);
deletedData.RootElement.GetProperty("deleteReason").GetString().ShouldBe("AllFilesBlocked");
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1)
@@ -191,6 +202,7 @@ public class MalwareBlockerIntegrationTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
DeleteReason = DeleteReason.AllFilesBlocked,
IsPrivate = true
@@ -229,10 +241,14 @@ public class MalwareBlockerIntegrationTests : IDisposable
deletedEvent.IsDryRun.ShouldBe(false);
deletedEvent.StrikeId.ShouldBeNull();
deletedEvent.SearchStatus.ShouldBeNull();
deletedEvent.ItemTitle.ShouldBe("Suspicious.Movie.2024.1080p");
deletedEvent.ItemHash.ShouldBe("MALWARE_HASH_789");
deletedEvent.RemoveFromClient.ShouldBe(false);
deletedEvent.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
deletedEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(deletedEvent.Data!))
{
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Suspicious.Movie.2024.1080p");
data.RootElement.GetProperty("hash").GetString().ShouldBe("MALWARE_HASH_789");
data.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(false);
data.RootElement.GetProperty("deleteReason").GetString().ShouldBe("AllFilesBlocked");
}
await _fixture.NotificationPublisher.Received(1)
.NotifyQueueItemDeleted(false, DeleteReason.AllFilesBlocked);
@@ -1,3 +1,4 @@
using System.Text.Json;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -110,8 +111,12 @@ public class QueueCleanerIntegrationTests : IDisposable
markedEvent.SearchStatus.ShouldBeNull();
markedEvent.CompletedAt.ShouldBeNull();
markedEvent.CycleId.ShouldBeNull();
markedEvent.ItemTitle.ShouldBe("Test.Movie.2024.1080p");
markedEvent.ItemHash.ShouldBe("ABC123DEF456");
markedEvent.Data.ShouldNotBeNull();
using (var markedData = JsonDocument.Parse(markedEvent.Data!))
{
markedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p");
markedData.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456");
}
// QueueItemDeleted event
var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted);
@@ -126,10 +131,14 @@ public class QueueCleanerIntegrationTests : IDisposable
deletedEvent.SearchStatus.ShouldBeNull();
deletedEvent.CompletedAt.ShouldBeNull();
deletedEvent.CycleId.ShouldBeNull();
deletedEvent.ItemTitle.ShouldBe("Test.Movie.2024.1080p");
deletedEvent.ItemHash.ShouldBe("ABC123DEF456");
deletedEvent.RemoveFromClient.ShouldBe(true);
deletedEvent.DeleteReason.ShouldBe(DeleteReason.Stalled);
deletedEvent.Data.ShouldNotBeNull();
using (var deletedData = JsonDocument.Parse(deletedEvent.Data!))
{
deletedData.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p");
deletedData.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456");
deletedData.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(true);
deletedData.RootElement.GetProperty("deleteReason").GetString().ShouldBe("Stalled");
}
// Assert Phase 4: Notification was triggered
await _fixture.NotificationPublisher.Received(1).NotifyQueueItemDeleted(true, DeleteReason.Stalled);
@@ -193,10 +202,14 @@ public class QueueCleanerIntegrationTests : IDisposable
deletedEvent.IsDryRun.ShouldBe(false);
deletedEvent.StrikeId.ShouldBeNull();
deletedEvent.SearchStatus.ShouldBeNull();
deletedEvent.ItemTitle.ShouldBe("Test.Movie.2024.1080p");
deletedEvent.ItemHash.ShouldBe("ABC123DEF456");
deletedEvent.RemoveFromClient.ShouldBe(true);
deletedEvent.DeleteReason.ShouldBe(DeleteReason.FailedImport);
deletedEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(deletedEvent.Data!))
{
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p");
data.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456");
data.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(true);
data.RootElement.GetProperty("deleteReason").GetString().ShouldBe("FailedImport");
}
// Notification with FailedImport reason
await _fixture.NotificationPublisher.Received(1).NotifyQueueItemDeleted(true, DeleteReason.FailedImport);
@@ -290,10 +303,14 @@ public class QueueCleanerIntegrationTests : IDisposable
deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
deletedEvent.ArrInstanceId.ShouldBe(instance.Id);
deletedEvent.IsDryRun.ShouldBe(false);
deletedEvent.ItemTitle.ShouldBe("Test.Movie.2024.1080p");
deletedEvent.ItemHash.ShouldBe("ABC123DEF456");
deletedEvent.RemoveFromClient.ShouldBe(false);
deletedEvent.DeleteReason.ShouldBe(DeleteReason.Stalled);
deletedEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(deletedEvent.Data!))
{
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Test.Movie.2024.1080p");
data.RootElement.GetProperty("hash").GetString().ShouldBe("ABC123DEF456");
data.RootElement.GetProperty("removeFromClient").GetBoolean().ShouldBe(false);
data.RootElement.GetProperty("deleteReason").GetString().ShouldBe("Stalled");
}
await _fixture.NotificationPublisher.Received(1).NotifyQueueItemDeleted(false, DeleteReason.Stalled);
}
@@ -89,12 +89,16 @@ public class SeekerIntegrationTests : IDisposable
searchEvent.CycleId.ShouldBeNull();
searchEvent.StrikeId.ShouldBeNull();
searchEvent.TrackingId.ShouldBeNull();
searchEvent.Data.ShouldBeNull();
// Assert: search fields were populated on the event
searchEvent.SearchType.ShouldBe(SeekerSearchType.Replacement);
searchEvent.SearchReason.ShouldBe(SeekerSearchReason.Replacement);
searchEvent.ItemTitle.ShouldBe("Test.Movie.2024.1080p");
searchEvent.GrabbedItems.ShouldBeEmpty();
// Assert: SearchEventData was created with correct properties
var searchData = await _fixture.EventsContext.SearchEventData.ToListAsync();
searchData.Count.ShouldBe(1);
searchData[0].AppEventId.ShouldBe(searchEvent.Id);
searchData[0].SearchType.ShouldBe(SeekerSearchType.Replacement);
searchData[0].SearchReason.ShouldBe(SeekerSearchReason.Replacement);
searchData[0].ItemTitle.ShouldBe("Test.Movie.2024.1080p");
searchData[0].GrabbedItems.ShouldBeEmpty();
// Assert: Notification was sent
await _fixture.NotificationPublisher.Received(1).NotifySearchTriggered(
@@ -145,6 +149,9 @@ public class SeekerIntegrationTests : IDisposable
var events = await _fixture.EventsContext.Events.ToListAsync();
events.ShouldBeEmpty();
var searchData = await _fixture.EventsContext.SearchEventData.ToListAsync();
searchData.ShouldBeEmpty();
await _fixture.NotificationPublisher.DidNotReceive().NotifySearchTriggered(
Arg.Any<string>(), Arg.Any<SeekerSearchType>(), Arg.Any<SeekerSearchReason>());
@@ -193,12 +200,15 @@ public class SeekerIntegrationTests : IDisposable
searchEvent.CompletedAt.ShouldBeNull();
searchEvent.CycleId.ShouldBeNull();
searchEvent.StrikeId.ShouldBeNull();
searchEvent.Data.ShouldBeNull();
// Assert: search fields were populated on the event
searchEvent.ItemTitle.ShouldBe("DryRun.Movie.2024");
searchEvent.SearchType.ShouldBe(SeekerSearchType.Replacement);
searchEvent.SearchReason.ShouldBe(SeekerSearchReason.Replacement);
searchEvent.GrabbedItems.ShouldBeEmpty();
// Assert: SearchEventData created
var searchData = await _fixture.EventsContext.SearchEventData.ToListAsync();
searchData.Count.ShouldBe(1);
searchData[0].ItemTitle.ShouldBe("DryRun.Movie.2024");
searchData[0].SearchType.ShouldBe(SeekerSearchType.Replacement);
searchData[0].SearchReason.ShouldBe(SeekerSearchReason.Replacement);
searchData[0].GrabbedItems.ShouldBeEmpty();
// Assert: Item remains in queue (dry run doesn't dequeue)
var remainingItems = await _fixture.DataContext.SearchQueue.CountAsync();
@@ -1,3 +1,4 @@
using System.Text.Json;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
@@ -81,39 +82,19 @@ public class StrikerIntegrationTests : IDisposable
strikeEvent.SearchStatus.ShouldBeNull();
strikeEvent.CompletedAt.ShouldBeNull();
strikeEvent.CycleId.ShouldBeNull();
strikeEvent.ItemHash.ShouldBe("STALLED_HASH_123");
strikeEvent.ItemTitle.ShouldBe("Stalled.Movie.2024.1080p");
strikeEvent.StrikeCount.ShouldBe(1);
strikeEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(strikeEvent.Data!))
{
data.RootElement.GetProperty("hash").GetString().ShouldBe("STALLED_HASH_123");
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Stalled.Movie.2024.1080p");
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1);
data.RootElement.GetProperty("strikeType").GetString().ShouldBe("Stalled");
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.Stalled, 1);
}
[Fact]
public async Task ResetStrikeAsync_ClearsActiveStrikes_PublishesStrikeResetEvent()
{
// Arrange: two stalled strikes on the same item
await _fixture.Striker.StrikeAndCheckLimit("RESET_HASH", "Recovered.Movie.2024", maxStrikes: 5, StrikeType.Stalled);
await _fixture.Striker.StrikeAndCheckLimit("RESET_HASH", "Recovered.Movie.2024", maxStrikes: 5, StrikeType.Stalled);
(await _fixture.EventsContext.Strikes.CountAsync()).ShouldBe(2);
// Act
await _fixture.Striker.ResetStrikeAsync("RESET_HASH", "Recovered.Movie.2024", StrikeType.Stalled);
// Assert: active strikes of that type are cleared (history lives in the event stream)
(await _fixture.EventsContext.Strikes.ToListAsync()).ShouldBeEmpty();
// Assert: exactly one StrikeReset event, with typed payload
var resetEvents = await _fixture.EventsContext.Events
.Where(e => e.EventType == EventType.StrikeReset)
.ToListAsync();
resetEvents.Count.ShouldBe(1);
resetEvents[0].Severity.ShouldBe(EventSeverity.Information);
resetEvents[0].ItemHash.ShouldBe("RESET_HASH");
resetEvents[0].ItemTitle.ShouldBe("Recovered.Movie.2024");
resetEvents[0].StrikeCount.ShouldBe(2);
}
[Fact]
public async Task DownloadingMetadataStrike_PublishesEvent_CreatesStrike_SendsNotification()
{
@@ -159,9 +140,14 @@ public class StrikerIntegrationTests : IDisposable
strikeEvent.SearchStatus.ShouldBeNull();
strikeEvent.CompletedAt.ShouldBeNull();
strikeEvent.CycleId.ShouldBeNull();
strikeEvent.ItemHash.ShouldBe("METADATA_HASH_456");
strikeEvent.ItemTitle.ShouldBe("Metadata.Movie.2024.1080p");
strikeEvent.StrikeCount.ShouldBe(1);
strikeEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(strikeEvent.Data!))
{
data.RootElement.GetProperty("hash").GetString().ShouldBe("METADATA_HASH_456");
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Metadata.Movie.2024.1080p");
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1);
data.RootElement.GetProperty("strikeType").GetString().ShouldBe("DownloadingMetadata");
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.DownloadingMetadata, 1);
@@ -230,15 +216,23 @@ public class StrikerIntegrationTests : IDisposable
strikeEvent.SearchStatus.ShouldBeNull();
strikeEvent.CompletedAt.ShouldBeNull();
strikeEvent.CycleId.ShouldBeNull();
strikeEvent.ItemHash.ShouldBe("FAILED_HASH_789");
strikeEvent.ItemTitle.ShouldBe("FailedImport.Movie.2024.1080p");
strikeEvent.StrikeCount.ShouldBe(1);
strikeEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(strikeEvent.Data!))
{
data.RootElement.GetProperty("hash").GetString().ShouldBe("FAILED_HASH_789");
data.RootElement.GetProperty("itemName").GetString().ShouldBe("FailedImport.Movie.2024.1080p");
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1);
data.RootElement.GetProperty("strikeType").GetString().ShouldBe("FailedImport");
// FailedImport-specific: includes failedImportReasons from QueueRecord.StatusMessages
strikeEvent.FailedImportReasons.Count.ShouldBe(1);
strikeEvent.FailedImportReasons[0].ShouldContain("Import failed");
strikeEvent.FailedImportReasons[0].ShouldContain("File not found");
strikeEvent.FailedImportReasons[0].ShouldContain("Path does not exist");
// FailedImport-specific: includes failedImportReasons from QueueRecord.StatusMessages
var reasons = data.RootElement.GetProperty("failedImportReasons");
reasons.GetArrayLength().ShouldBe(1);
reasons[0].GetProperty("Title").GetString().ShouldBe("Import failed");
var messages = reasons[0].GetProperty("Messages");
messages.GetArrayLength().ShouldBe(2);
messages[0].GetString().ShouldBe("File not found");
messages[1].GetString().ShouldBe("Path does not exist");
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.FailedImport, 1);
@@ -289,9 +283,14 @@ public class StrikerIntegrationTests : IDisposable
strikeEvent.SearchStatus.ShouldBeNull();
strikeEvent.CompletedAt.ShouldBeNull();
strikeEvent.CycleId.ShouldBeNull();
strikeEvent.ItemHash.ShouldBe("SLOW_SPEED_HASH_111");
strikeEvent.ItemTitle.ShouldBe("SlowSpeed.Movie.2024.1080p");
strikeEvent.StrikeCount.ShouldBe(1);
strikeEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(strikeEvent.Data!))
{
data.RootElement.GetProperty("hash").GetString().ShouldBe("SLOW_SPEED_HASH_111");
data.RootElement.GetProperty("itemName").GetString().ShouldBe("SlowSpeed.Movie.2024.1080p");
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1);
data.RootElement.GetProperty("strikeType").GetString().ShouldBe("SlowSpeed");
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.SlowSpeed, 1);
@@ -342,9 +341,14 @@ public class StrikerIntegrationTests : IDisposable
strikeEvent.SearchStatus.ShouldBeNull();
strikeEvent.CompletedAt.ShouldBeNull();
strikeEvent.CycleId.ShouldBeNull();
strikeEvent.ItemHash.ShouldBe("SLOW_TIME_HASH_222");
strikeEvent.ItemTitle.ShouldBe("SlowTime.Movie.2024.1080p");
strikeEvent.StrikeCount.ShouldBe(1);
strikeEvent.Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(strikeEvent.Data!))
{
data.RootElement.GetProperty("hash").GetString().ShouldBe("SLOW_TIME_HASH_222");
data.RootElement.GetProperty("itemName").GetString().ShouldBe("SlowTime.Movie.2024.1080p");
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(1);
data.RootElement.GetProperty("strikeType").GetString().ShouldBe("SlowTime");
}
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.SlowTime, 1);
@@ -382,7 +386,8 @@ public class StrikerIntegrationTests : IDisposable
for (int i = 0; i < 3; i++)
{
events[i].EventType.ShouldBe(EventType.StalledStrike);
events[i].StrikeCount.ShouldBe(i + 1);
using var data = JsonDocument.Parse(events[i].Data!);
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(i + 1);
}
// Assert: 3 notifications with incrementing counts
@@ -444,9 +449,13 @@ public class StrikerIntegrationTests : IDisposable
manualEvents[0].Message.ShouldContain("Download keeps coming back after deletion");
manualEvents[0].Severity.ShouldBe(EventSeverity.Important);
manualEvents[0].JobRunId.ShouldBe(_fixture.JobRunId);
manualEvents[0].ItemTitle.ShouldBe("Recurring.Movie.2024");
manualEvents[0].ItemHash.ShouldBe("recurring_hash_555"); // stored normalized (lowercased) for case-insensitive dedup
manualEvents[0].StrikeCount.ShouldBe(3);
manualEvents[0].Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(manualEvents[0].Data!))
{
data.RootElement.GetProperty("itemName").GetString().ShouldBe("Recurring.Movie.2024");
data.RootElement.GetProperty("hash").GetString().ShouldBe("RECURRING_HASH_555");
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(3);
}
}
[Fact]
@@ -484,6 +493,11 @@ public class StrikerIntegrationTests : IDisposable
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(1);
events[0].EventType.ShouldBe(EventType.FailedImportStrike);
events[0].FailedImportReasons.ShouldBeEmpty();
events[0].Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(events[0].Data!))
{
var reasons = data.RootElement.GetProperty("failedImportReasons");
reasons.GetArrayLength().ShouldBe(0);
}
}
}
@@ -5,6 +5,7 @@ using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Infrastructure.Tests.TestHelpers;
@@ -49,7 +50,8 @@ public class MalwareBlockerTests : IDisposable
_fixture.ArrQueueIterator,
_fixture.DownloadServiceFactory,
_fixture.BlocklistProvider,
_fixture.EventPublisher
_fixture.EventPublisher,
_fixture.JobManagementService
);
}
@@ -276,7 +278,7 @@ public class MalwareBlockerTests : IDisposable
Arg.Any<string>(),
Arg.Any<List<string>>()
)
.Returns(new BlockFilesResult { Found = true, ShouldRemove = false });
.Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = false });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
@@ -339,6 +341,7 @@ public class MalwareBlockerTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AllFilesBlocked
@@ -362,6 +365,215 @@ public class MalwareBlockerTests : IDisposable
);
}
[Fact]
public async Task ExecuteInternalAsync_WhenWebhookTarget_ScansOnlyMatchingDownload()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var matching = new QueueRecord { Id = 1, DownloadId = "match-hash", Title = "Match", Protocol = "torrent", SeriesId = 5, EpisodeId = 1 };
var other = new QueueRecord { Id = 2, DownloadId = "other-hash", Title = "Other", Protocol = "torrent", SeriesId = 5, EpisodeId = 2 };
_fixture.ArrQueueIterator
.Iterate(
Arg.Any<IArrClient>(),
Arg.Any<ArrInstance>(),
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(),
Arg.Any<long?>()
)
.Returns(ci =>
{
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
return callback([matching, other]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = false });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
sonarrInstance.Id, "match-hash", 5, InstanceType.Sonarr));
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await mockDownloadService.Received(1).BlockUnwantedFilesAsync("match-hash", Arg.Any<List<string>>());
await mockDownloadService.DidNotReceive().BlockUnwantedFilesAsync("other-hash", Arg.Any<List<string>>());
// Found in a client -> resolved, no retry scheduled
await _fixture.JobManagementService.DidNotReceive()
.ScheduleMalwareBlockerWebhookRetry(Arg.Any<WebhookScanTarget>());
}
[Fact]
public async Task ExecuteInternalAsync_WhenWebhookTargetNotFoundInClient_SchedulesRetry()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var record = new QueueRecord { Id = 1, DownloadId = "pending-hash", Title = "Pending", Protocol = "torrent", SeriesId = 7, EpisodeId = 1 };
_fixture.ArrQueueIterator
.Iterate(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(), Arg.Any<long?>())
.Returns(ci =>
{
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
return callback([record]);
});
// Torrent not (yet) present in any client
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.Returns(new BlockFilesResult { Found = false });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
sonarrInstance.Id, "pending-hash", 7, InstanceType.Sonarr, RetryIndex: 1));
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await _fixture.JobManagementService.Received(1).ScheduleMalwareBlockerWebhookRetry(
Arg.Is<WebhookScanTarget>(t =>
t.InstanceId == sonarrInstance.Id &&
t.DownloadId == "pending-hash" &&
t.ContentId == 7 &&
t.Type == InstanceType.Sonarr &&
t.RetryIndex == 1));
}
[Fact]
public async Task ExecuteInternalAsync_WhenWebhookTargetMetadataMissing_SchedulesRetry()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var record = new QueueRecord { Id = 1, DownloadId = "metadl-hash", Title = "MetaDL", Protocol = "torrent", SeriesId = 7, EpisodeId = 1 };
_fixture.ArrQueueIterator
.Iterate(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(), Arg.Any<long?>())
.Returns(ci =>
{
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
return callback([record]);
});
// Torrent found in the client, but its metadata/file list is not ready yet
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.Returns(new BlockFilesResult { Found = true });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
sonarrInstance.Id, "metadl-hash", 7, InstanceType.Sonarr, RetryIndex: 0));
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await _fixture.JobManagementService.Received(1).ScheduleMalwareBlockerWebhookRetry(
Arg.Is<WebhookScanTarget>(t => t.DownloadId == "metadl-hash" && t.RetryIndex == 0));
}
[Fact]
public async Task ExecuteInternalAsync_WhenWebhookTargetIsUsenet_DoesNotScanOrRetry()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var record = new QueueRecord { Id = 1, DownloadId = "usenet-id", Title = "Usenet", Protocol = "usenet", SeriesId = 9, EpisodeId = 1 };
_fixture.ArrQueueIterator
.Iterate(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(), Arg.Any<long?>())
.Returns(ci =>
{
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
return callback([record]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
sonarrInstance.Id, "usenet-id", 9, InstanceType.Sonarr));
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: usenet is acknowledged once seen in the queue -> no scan, no retry
await mockDownloadService.DidNotReceive().BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>());
await _fixture.JobManagementService.DidNotReceive()
.ScheduleMalwareBlockerWebhookRetry(Arg.Any<WebhookScanTarget>());
}
[Fact]
public async Task ProcessInstanceAsync_WhenShouldRemoveWithAtLeastOneFileBlocked_PublishesRemoveRequest()
{
@@ -409,6 +621,7 @@ public class MalwareBlockerTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AtLeastOneFileBlocked
@@ -484,6 +697,7 @@ public class MalwareBlockerTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
IsPrivate = true,
DeleteReason = DeleteReason.AllFilesBlocked
@@ -665,6 +879,7 @@ public class MalwareBlockerTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AllFilesBlocked
@@ -7,6 +7,7 @@ using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
@@ -30,6 +31,7 @@ public class JobHandlerFixture : IDisposable
public IDownloadServiceFactory DownloadServiceFactory { get; private set; }
public IEventPublisher EventPublisher { get; private set; }
public IBlocklistProvider BlocklistProvider { get; private set; }
public IJobManagementService JobManagementService { get; private set; }
public IHardLinkFileService HardLinkFileService { get; private set; }
public IDryRunInterceptor DryRunInterceptor { get; private set; }
public FakeTimeProvider TimeProvider { get; private set; }
@@ -52,6 +54,7 @@ public class JobHandlerFixture : IDisposable
DownloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
JobManagementService = Substitute.For<IJobManagementService>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
TimeProvider = new FakeTimeProvider();
@@ -151,6 +154,7 @@ public class JobHandlerFixture : IDisposable
DownloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
JobManagementService = Substitute.For<IJobManagementService>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
Cache.Clear();
@@ -21,7 +21,6 @@ public static class TestEventsContextFactory
var options = new DbContextOptionsBuilder<EventsContext>()
.UseSqlite(connection)
.UseSnakeCaseNamingConvention()
.Options;
var context = new EventsContext(options);
@@ -512,63 +512,4 @@ public class JobManagementServiceTests
}
#endregion
#region GetMainTrigger Tests
[Fact]
public async Task GetMainTrigger_JobDoesNotExist_ReturnsNull()
{
// Arrange
var jobType = JobType.QueueCleaner;
_scheduler.CheckExists(Arg.Any<JobKey>(), Arg.Any<CancellationToken>())
.Returns(false);
// Act
var result = await _service.GetMainTrigger(jobType);
// Assert
result.ShouldBeNull();
}
[Fact]
public async Task GetMainTrigger_TriggerExists_ReturnsTrigger()
{
// Arrange
var jobType = JobType.MalwareBlocker;
var expectedTriggerKey = new TriggerKey("MalwareBlocker-trigger");
var trigger = Substitute.For<ITrigger>();
trigger.Key.Returns(expectedTriggerKey);
_scheduler.CheckExists(Arg.Any<JobKey>(), Arg.Any<CancellationToken>())
.Returns(true);
_scheduler.GetTrigger(expectedTriggerKey, Arg.Any<CancellationToken>())
.Returns(trigger);
// Act
var result = await _service.GetMainTrigger(jobType);
// Assert
result.ShouldNotBeNull();
result.Key.ShouldBe(expectedTriggerKey);
}
[Fact]
public async Task GetMainTrigger_WhenSchedulerThrows_ReturnsNull()
{
// Arrange
var jobType = JobType.QueueCleaner;
_scheduler.CheckExists(Arg.Any<JobKey>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.GetMainTrigger(jobType);
// Assert
result.ShouldBeNull();
}
#endregion
}
@@ -1,229 +0,0 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Stats;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Stats;
public class StatsServiceV2Tests : IDisposable
{
private readonly EventsContext _context;
private readonly StatsService _service;
public StatsServiceV2Tests()
{
_context = TestEventsContextFactory.Create();
IHealthCheckService health = Substitute.For<IHealthCheckService>();
health.GetAllClientHealth().Returns(new Dictionary<Guid, HealthStatus>());
health.GetAllArrInstanceHealth().Returns(new Dictionary<Guid, ArrHealthStatus>());
IJobManagementService jobs = Substitute.For<IJobManagementService>();
jobs.GetAllJobs().ReturnsForAnyArgs(Task.FromResult<IReadOnlyList<JobInfo>>([]));
_service = new StatsService(Substitute.For<ILogger<StatsService>>(), _context, health, jobs);
}
public void Dispose()
{
_context.Dispose();
GC.SuppressFinalize(this);
}
private static AppEvent Event(
EventType type,
DeleteReason? deleteReason = null,
CleanReason? cleanReason = null,
SearchCommandStatus? searchStatus = null,
SeekerSearchReason? searchReason = null,
List<string>? grabbedItems = null,
bool isDryRun = false,
DateTimeOffset? timestamp = null) => new()
{
EventType = type,
Message = type.ToString(),
Severity = EventSeverity.Information,
Timestamp = timestamp ?? DateTimeOffset.UtcNow.AddHours(-1),
DeleteReason = deleteReason,
CleanReason = cleanReason,
SearchStatus = searchStatus,
SearchReason = searchReason,
GrabbedItems = grabbedItems ?? [],
IsDryRun = isDryRun,
};
[Fact]
public async Task GetStatsV2Async_DerivesTimeframeMetricsFromEvents()
{
_context.Events.Add(Event(EventType.StalledStrike));
_context.Events.Add(Event(EventType.StalledStrike));
_context.Events.Add(Event(EventType.FailedImportStrike));
_context.Events.Add(Event(EventType.StrikeReset));
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.AllFilesBlocked));
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled));
await _context.SaveChangesAsync();
StatsV2Response stats = await _service.GetStatsV2Async(24);
stats.TimeframeHours.ShouldBe(24);
stats.Events.Total.ShouldBe(6);
stats.Events.ByType["StalledStrike"].ShouldBe(2);
stats.Strikes.Total.ShouldBe(3);
stats.Strikes.ByType["Stalled"].ShouldBe(2);
stats.Strikes.ByType["FailedImport"].ShouldBe(1);
stats.Strikes.Total.ShouldBe(stats.Strikes.ByType.Values.Sum());
stats.Strikes.Recovered.ShouldBe(1);
stats.Removals.Total.ShouldBe(2);
stats.Removals.ByReason["AllFilesBlocked"].ShouldBe(1);
stats.Removals.ByReason["Stalled"].ShouldBe(1);
}
[Fact]
public async Task GetStatsV2Async_MalwareIsDerivedFromRemovalReasons()
{
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.AllFilesBlocked));
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.AtLeastOneFileBlocked));
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.SlowSpeed));
await _context.SaveChangesAsync();
StatsV2Response stats = await _service.GetStatsV2Async(24);
int malware = stats.Removals.ByReason.GetValueOrDefault("AllFilesBlocked")
+ stats.Removals.ByReason.GetValueOrDefault("AtLeastOneFileBlocked");
malware.ShouldBe(2);
stats.Removals.Total.ShouldBe(3);
}
[Fact]
public async Task GetStatsV2Async_StrikesRespectTimeframe()
{
_context.Events.Add(Event(EventType.StalledStrike));
_context.Events.Add(Event(EventType.StalledStrike, timestamp: DateTimeOffset.UtcNow.AddHours(-100)));
await _context.SaveChangesAsync();
StatsV2Response stats = await _service.GetStatsV2Async(24);
stats.Strikes.Total.ShouldBe(1);
stats.Strikes.ByType["Stalled"].ShouldBe(1);
}
[Fact]
public async Task GetStatsV2Async_ExcludesDryRunByDefault()
{
_context.Events.Add(Event(EventType.StalledStrike));
_context.Events.Add(Event(EventType.StalledStrike, isDryRun: true));
await _context.SaveChangesAsync();
StatsV2Response live = await _service.GetStatsV2Async(24);
live.Strikes.Total.ShouldBe(1);
live.Events.ByType["StalledStrike"].ShouldBe(1);
StatsV2Response withDryRun = await _service.GetStatsV2Async(24, includeDryRun: true);
withDryRun.Strikes.Total.ShouldBe(2);
withDryRun.Events.ByType["StalledStrike"].ShouldBe(2);
}
[Fact]
public async Task GetStatsV2Async_CleanedGroupsByReasonSkippingNone()
{
_context.Events.Add(Event(EventType.DownloadCleaned, cleanReason: CleanReason.MaxRatioReached));
_context.Events.Add(Event(EventType.DownloadCleaned, cleanReason: CleanReason.MaxRatioReached));
_context.Events.Add(Event(EventType.DownloadCleaned, cleanReason: CleanReason.MaxSeedTimeReached));
_context.Events.Add(Event(EventType.DownloadCleaned, cleanReason: CleanReason.None));
await _context.SaveChangesAsync();
StatsV2Response stats = await _service.GetStatsV2Async(24);
stats.Cleaned.Total.ShouldBe(4);
stats.Cleaned.ByReason["MaxRatioReached"].ShouldBe(2);
stats.Cleaned.ByReason["MaxSeedTimeReached"].ShouldBe(1);
stats.Cleaned.ByReason.ShouldNotContainKey("None");
}
[Fact]
public async Task GetStatsV2Async_SearchesAggregateStatusReasonAndGrabbed()
{
_context.Events.Add(Event(EventType.SearchTriggered, searchStatus: SearchCommandStatus.Completed,
searchReason: SeekerSearchReason.Missing, grabbedItems: ["a", "b"]));
_context.Events.Add(Event(EventType.SearchTriggered, searchStatus: SearchCommandStatus.Completed,
searchReason: SeekerSearchReason.QualityCutoffNotMet, grabbedItems: ["c"]));
_context.Events.Add(Event(EventType.SearchTriggered, searchStatus: SearchCommandStatus.Failed,
searchReason: SeekerSearchReason.Missing));
_context.Events.Add(Event(EventType.SearchTriggered, searchStatus: SearchCommandStatus.TimedOut,
searchReason: SeekerSearchReason.Replacement));
_context.Events.Add(Event(EventType.SearchTriggered, searchStatus: SearchCommandStatus.Pending,
searchReason: SeekerSearchReason.Missing));
await _context.SaveChangesAsync();
StatsV2Response stats = await _service.GetStatsV2Async(24);
stats.Searches.Total.ShouldBe(5);
stats.Searches.Completed.ShouldBe(2);
stats.Searches.Failed.ShouldBe(2);
stats.Searches.Grabbed.ShouldBe(3);
stats.Searches.ByReason["Missing"].ShouldBe(3);
stats.Searches.ByReason["QualityCutoffNotMet"].ShouldBe(1);
stats.Searches.ByReason["Replacement"].ShouldBe(1);
}
[Fact]
public async Task GetTimelineAsync_FiltersByMetricAndDryRun()
{
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.AllFilesBlocked));
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled));
_context.Events.Add(Event(EventType.StrikeReset));
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.SlowSpeed, isDryRun: true));
await _context.SaveChangesAsync();
List<TimelineBucketDto> removed = await _service.GetTimelineAsync("removed", 24);
removed.Sum(b => b.Count).ShouldBe(2);
List<TimelineBucketDto> removedWithDryRun = await _service.GetTimelineAsync("removed", 24, includeDryRun: true);
removedWithDryRun.Sum(b => b.Count).ShouldBe(3);
List<TimelineBucketDto> malware = await _service.GetTimelineAsync("malwareBlocked", 24);
malware.Sum(b => b.Count).ShouldBe(1);
}
[Fact]
public async Task GetTimelineAsync_MonthBucketsAreFirstOfMonth()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now));
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now.AddDays(-40)));
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now.AddDays(-75)));
await _context.SaveChangesAsync();
List<TimelineBucketDto> series = await _service.GetTimelineAsync("removed", 8760, TimelineBucketSize.Month);
series.Sum(b => b.Count).ShouldBe(3);
series.Count(b => b.Count > 0).ShouldBe(3);
series.ShouldAllBe(b => b.Date.Day == 1);
}
[Fact]
public async Task GetTimelineAsync_WeekBucketsStartOnMonday()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now));
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now.AddDays(-10)));
_context.Events.Add(Event(EventType.QueueItemDeleted, deleteReason: DeleteReason.Stalled, timestamp: now.AddDays(-20)));
await _context.SaveChangesAsync();
List<TimelineBucketDto> series = await _service.GetTimelineAsync("removed", 720, TimelineBucketSize.Week);
series.Sum(b => b.Count).ShouldBe(3);
series.Count(b => b.Count > 0).ShouldBe(3);
series.ShouldAllBe(b => b.Date.DayOfWeek == DayOfWeek.Monday);
}
}
@@ -1,6 +1,4 @@
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -64,22 +62,16 @@ public class EventCleanupService : BackgroundService
var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
GeneralConfig config = await dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
var cutoffDate = DateTimeOffset.UtcNow.AddDays(-_eventRetentionDays);
await eventsContext.Events
.Where(e => e.Timestamp < cutoffDate)
.ExecuteDeleteAsync();
await eventsContext.ManualEvents
.Where(e => e.Timestamp < cutoffDate)
.Where(e => e.IsResolved)
.ExecuteDeleteAsync();
DateTimeOffset eventCutoff = DateTimeOffset.UtcNow.AddDays(-_eventRetentionDays);
// Resolved manual events are transient
await DeleteResolvedManualEventsAsync(eventsContext, eventCutoff);
// Prune events older than the configured retention window
await PruneEventsAsync(eventsContext, config.HistoryRetentionDays);
await CleanupStrikesAsync(eventsContext, config.StrikeInactivityWindowHours);
// Prune old job runs no longer referenced by any active strike or event
await PruneJobRunsAsync(eventsContext, eventCutoff);
await CleanupStrikesAsync(eventsContext, dataContext);
}
catch (Exception ex)
{
@@ -87,49 +79,13 @@ public class EventCleanupService : BackgroundService
}
}
internal async Task DeleteResolvedManualEventsAsync(EventsContext eventsContext, DateTimeOffset cutoff)
private async Task CleanupStrikesAsync(EventsContext eventsContext, DataContext dataContext)
{
int deleted = await eventsContext.ManualEvents
.Where(e => e.IsResolved)
.Where(e => (e.ResolvedAt ?? e.Timestamp) < cutoff)
.ExecuteDeleteAsync();
var config = await dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
if (deleted > 0)
{
_logger.LogInformation("Deleted {count} resolved manual events older than {days} days", deleted, _eventRetentionDays);
}
}
internal async Task PruneEventsAsync(EventsContext eventsContext, ushort retentionDays)
{
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-retentionDays);
int deleted = await eventsContext.Events
.Where(e => e.Timestamp < cutoff)
.ExecuteDeleteAsync();
if (deleted > 0)
{
_logger.LogInformation("Pruned {count} events older than {days} days", deleted, retentionDays);
}
}
internal async Task PruneJobRunsAsync(EventsContext eventsContext, DateTimeOffset cutoff)
{
int deleted = await eventsContext.JobRuns
.Where(j => j.CompletedAt != null && j.StartedAt < cutoff)
.Where(j => !eventsContext.Strikes.Any(s => s.JobRunId == j.Id))
.Where(j => !eventsContext.Events.Any(e => e.JobRunId == j.Id))
.Where(j => !eventsContext.ManualEvents.Any(m => m.JobRunId == j.Id))
.ExecuteDeleteAsync();
if (deleted > 0)
{
_logger.LogInformation("Pruned {count} unreferenced job runs", deleted);
}
}
private async Task CleanupStrikesAsync(EventsContext eventsContext, ushort inactivityWindowHours)
{
var inactivityWindowHours = config.StrikeInactivityWindowHours;
var cutoffDate = DateTimeOffset.UtcNow.AddHours(-inactivityWindowHours);
// Sliding window: find items whose most recent strike is older than the inactivity window.
@@ -1,3 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events.Interfaces;
@@ -9,8 +11,8 @@ using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Events;
@@ -27,7 +29,7 @@ public class EventPublisher : IEventPublisher
private readonly IDryRunInterceptor _dryRunInterceptor;
public EventPublisher(
EventsContext context,
EventsContext context,
IHubContext<AppHub> appHubContext,
ILogger<EventPublisher> logger,
INotificationPublisher notificationPublisher,
@@ -41,16 +43,19 @@ public class EventPublisher : IEventPublisher
}
/// <summary>
/// Generic method for publishing events to database and SignalR clients.
/// Common context fields are populated here; <paramref name="configure"/> sets event-type-specific typed fields.
/// Generic method for publishing events to database and SignalR clients
/// </summary>
public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, Action<AppEvent>? configure = null, Guid? trackingId = null, Guid? strikeId = null, bool? isDryRun = null)
public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null, bool? isDryRun = null)
{
AppEvent eventEntity = new()
{
EventType = eventType,
Message = message,
Severity = severity,
Data = data != null ? JsonSerializer.Serialize(data, new JsonSerializerOptions
{
Converters = { new JsonStringEnumConverter() }
}) : null,
TrackingId = trackingId,
StrikeId = strikeId,
JobRunId = ContextProvider.TryGetJobRunId(),
@@ -62,8 +67,6 @@ public class EventPublisher : IEventPublisher
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
};
configure?.Invoke(eventEntity);
eventEntity.IsDryRun = isDryRun ?? await _dryRunInterceptor.IsDryRunEnabled();
_context.Events.Add(eventEntity);
@@ -74,19 +77,16 @@ public class EventPublisher : IEventPublisher
_logger.LogTrace("Published event: {eventType}", eventType);
}
/// <summary>
/// Publishes a manual event, gated to avoid duplicates. Common context fields are populated here;
/// <paramref name="configure"/> sets event-type-specific typed fields. When an item hash is set,
/// the event is suppressed if an unresolved event of the same type/hash already exists, or if one
/// was resolved within the post-resolve cooldown window.
/// </summary>
public async Task PublishManualAsync(ManualEventType type, string message, EventSeverity severity, Action<ManualEvent>? configure = null, bool? isDryRun = null)
public async Task PublishManualAsync(string message, EventSeverity severity, object? data = null, bool? isDryRun = null)
{
ManualEvent eventEntity = new()
{
Type = type,
Message = message,
Severity = severity,
Data = data != null ? JsonSerializer.Serialize(data, new JsonSerializerOptions
{
Converters = { new JsonStringEnumConverter() }
}) : null,
JobRunId = ContextProvider.TryGetJobRunId(),
InstanceType = ContextProvider.Get(nameof(InstanceType)) is InstanceType it ? it : null,
InstanceUrl = (ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl) as Uri)?.ToString(),
@@ -94,45 +94,10 @@ public class EventPublisher : IEventPublisher
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
};
configure?.Invoke(eventEntity);
string? normalizedHash = eventEntity.ItemHash?.ToLowerInvariant();
eventEntity.ItemHash = normalizedHash;
if (normalizedHash is not null)
{
// ponytail: 1h cooldown is hardcoded by request; make it a config value only if it needs tuning.
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddHours(-1);
// Suppress if an unresolved event already exists (dedup) OR one was resolved < 1h ago (post-resolve cooldown).
bool suppress = await _context.ManualEvents.AnyAsync(e =>
e.Type == type &&
e.ItemHash == normalizedHash &&
(!e.IsResolved || (e.ResolvedAt != null && e.ResolvedAt >= cutoff)));
if (suppress)
{
_logger.LogDebug("Skipping manual event {type} for {hash} (unresolved or within cooldown)", type, normalizedHash);
return;
}
}
eventEntity.IsDryRun = isDryRun ?? await _dryRunInterceptor.IsDryRunEnabled();
try
{
_context.ManualEvents.Add(eventEntity);
await _context.SaveChangesAsync();
}
catch (DbUpdateException ex) when (normalizedHash is not null
&& ex.InnerException is SqliteException { SqliteErrorCode: 19 })
{
// SQLITE_CONSTRAINT (19): lost a race against the partial unique index — another run
// created it first. Treat as deduped. Any other failure is real and bubbles up.
_logger.LogDebug("Manual event {type} for {hash} rejected by unique index", type, normalizedHash);
_context.Entry(eventEntity).State = EntityState.Detached;
return;
}
_context.ManualEvents.Add(eventEntity);
await _context.SaveChangesAsync();
await NotifyClientsAsync(eventEntity);
@@ -156,17 +121,29 @@ public class EventPublisher : IEventPublisher
_ => throw new ArgumentOutOfRangeException(nameof(strikeType), strikeType, null)
};
List<string> failedImportReasons = [];
dynamic data;
if (strikeType is StrikeType.FailedImport)
{
QueueRecord record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
failedImportReasons = record.StatusMessages?
.Select(m => m.Messages is { Count: > 0 }
? $"{m.Title}: {string.Join("; ", m.Messages)}"
: m.Title)
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList() ?? [];
data = new
{
hash,
itemName,
strikeCount,
strikeType,
failedImportReasons = record.StatusMessages ?? [],
};
}
else
{
data = new
{
hash,
itemName,
strikeCount,
strikeType,
};
}
bool isDryRun = await _dryRunInterceptor.IsDryRunEnabled();
@@ -176,13 +153,7 @@ public class EventPublisher : IEventPublisher
eventType,
$"Item '{itemName}' has been struck {strikeCount} times for reason '{strikeType}'",
EventSeverity.Important,
configure: e =>
{
e.ItemTitle = itemName;
e.ItemHash = hash;
e.StrikeCount = strikeCount;
e.FailedImportReasons = failedImportReasons;
},
data: data,
strikeId: strikeId,
isDryRun: isDryRun);
@@ -193,23 +164,6 @@ public class EventPublisher : IEventPublisher
await _notificationPublisher.NotifyStrike(strikeType, strikeCount);
}
/// <summary>
/// Publishes a strike reset event: emitted when a download recovers and its strikes of a given type are cleared.
/// </summary>
public async Task PublishStrikeReset(StrikeType strikeType, int strikeCount, string hash, string itemName)
{
await PublishAsync(
EventType.StrikeReset,
$"'{itemName}' recovered — {strikeCount} '{strikeType}' strike(s) reset",
EventSeverity.Information,
configure: e =>
{
e.ItemTitle = itemName;
e.ItemHash = hash;
e.StrikeCount = strikeCount;
});
}
/// <summary>
/// Publishes a queue item deleted event with context data and notifications
/// </summary>
@@ -224,13 +178,7 @@ public class EventPublisher : IEventPublisher
EventType.QueueItemDeleted,
$"Deleting item from queue with reason: {deleteReason}",
EventSeverity.Important,
configure: e =>
{
e.ItemTitle = itemName;
e.ItemHash = hash;
e.DeleteReason = deleteReason;
e.RemoveFromClient = removeFromClient;
});
data: new { itemName, hash, removeFromClient, deleteReason });
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyQueueItemDeleted(removeFromClient, deleteReason);
@@ -250,15 +198,7 @@ public class EventPublisher : IEventPublisher
EventType.DownloadCleaned,
$"Cleaned item from download client with reason: {reason}",
EventSeverity.Important,
configure: e =>
{
e.ItemTitle = itemName;
e.ItemHash = hash;
e.CleanedCategory = categoryName;
e.SeedRatio = ratio;
e.SeedingTimeHours = seedingTime.TotalHours;
e.CleanReason = reason;
});
data: new { itemName, hash, categoryName, ratio, seedingTime = seedingTime.TotalHours, reason });
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason);
@@ -278,14 +218,7 @@ public class EventPublisher : IEventPublisher
EventType.CategoryChanged,
isTag ? $"Tag '{newCategory}' added to download" : $"Category changed from '{oldCategory}' to '{newCategory}'",
EventSeverity.Information,
configure: e =>
{
e.ItemTitle = itemName;
e.ItemHash = hash;
e.OldCategory = oldCategory;
e.NewCategory = newCategory;
e.IsCategoryTag = isTag;
});
data: new { itemName, hash, oldCategory, newCategory, isTag });
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyCategoryChanged(oldCategory, newCategory, isTag);
@@ -297,15 +230,9 @@ public class EventPublisher : IEventPublisher
public async Task PublishRecurringItem(string hash, string itemName, int strikeCount)
{
await PublishManualAsync(
ManualEventType.RecurringDownload,
"Download keeps coming back after deletion\nTo prevent further issues, please consult the prerequisites: https://cleanuparr.github.io/Cleanuparr/docs/installation/",
EventSeverity.Important,
configure: e =>
{
e.ItemTitle = itemName;
e.ItemHash = hash;
e.StrikeCount = strikeCount;
}
data: new { itemName, hash, strikeCount }
);
}
@@ -329,15 +256,31 @@ public class EventPublisher : IEventPublisher
DownloadClientType = ContextProvider.Get(ContextProvider.Keys.DownloadClientType) is DownloadClientTypeName dct ? dct : null,
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
CycleId = cycleId,
ItemTitle = itemTitle,
SearchType = searchType,
SearchReason = searchReason,
};
eventEntity.IsDryRun = await _dryRunInterceptor.IsDryRunEnabled();
_context.Events.Add(eventEntity);
await _context.SaveChangesAsync();
await using IDbContextTransaction transaction = await _context.Database.BeginTransactionAsync();
try
{
_context.Events.Add(eventEntity);
_context.SearchEventData.Add(new SearchEventData
{
AppEventId = eventEntity.Id,
ItemTitle = itemTitle,
SearchType = searchType,
SearchReason = searchReason,
});
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
await NotifyClientsAsync(eventEntity);
await _notificationPublisher.NotifySearchTriggered(itemTitle, searchType, searchReason);
@@ -351,6 +294,7 @@ public class EventPublisher : IEventPublisher
public async Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, InstanceType instanceType, string instanceUrl, List<string>? grabbedItems = null)
{
var existingEvent = await _context.Events
.Include(e => e.SearchEventData)
.FirstOrDefaultAsync(e => e.Id == eventId);
if (existingEvent is null)
@@ -362,17 +306,17 @@ public class EventPublisher : IEventPublisher
existingEvent.SearchStatus = status;
existingEvent.CompletedAt = DateTimeOffset.UtcNow;
if (grabbedItems is { Count: > 0 })
if (grabbedItems is { Count: > 0 } && existingEvent.SearchEventData is not null)
{
existingEvent.GrabbedItems = grabbedItems;
existingEvent.SearchEventData.GrabbedItems = grabbedItems;
}
await _context.SaveChangesAsync();
await NotifyClientsAsync(existingEvent);
if (status is SearchCommandStatus.Completed && grabbedItems is { Count: > 0 })
if (status is SearchCommandStatus.Completed && grabbedItems is { Count: > 0 } && existingEvent.SearchEventData is not null)
{
await _notificationPublisher.NotifySearchItemGrabbed(existingEvent.ItemTitle ?? string.Empty, grabbedItems, instanceType, instanceUrl);
await _notificationPublisher.NotifySearchItemGrabbed(existingEvent.SearchEventData.ItemTitle, grabbedItems, instanceType, instanceUrl);
}
}
@@ -382,14 +326,9 @@ public class EventPublisher : IEventPublisher
public async Task PublishSearchNotTriggered(string hash, string itemName)
{
await PublishManualAsync(
ManualEventType.SearchNotTriggered,
"Replacement search was not triggered after removal\nPlease trigger a manual search if needed",
EventSeverity.Warning,
configure: e =>
{
e.ItemTitle = itemName;
e.ItemHash = hash;
}
data: new { itemName, hash }
);
}
@@ -405,7 +344,7 @@ public class EventPublisher : IEventPublisher
_logger.LogError(ex, "Failed to send event {eventId} to SignalR clients", appEventEntity.Id);
}
}
private async Task NotifyClientsAsync(ManualEvent appEventEntity)
{
try
@@ -439,4 +378,4 @@ public class EventPublisher : IEventPublisher
_logger.LogError(ex, "Failed to send strike to SignalR clients");
}
}
}
}
@@ -1,18 +1,15 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Events;
namespace Cleanuparr.Infrastructure.Events.Interfaces;
public interface IEventPublisher
{
Task PublishAsync(EventType eventType, string message, EventSeverity severity, Action<AppEvent>? configure = null, Guid? trackingId = null, Guid? strikeId = null, bool? isDryRun = null);
Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null, bool? isDryRun = null);
Task PublishManualAsync(ManualEventType type, string message, EventSeverity severity, Action<ManualEvent>? configure = null, bool? isDryRun = null);
Task PublishManualAsync(string message, EventSeverity severity, object? data = null, bool? isDryRun = null);
Task PublishStrike(StrikeType strikeType, int strikeCount, string hash, string itemName, Guid? strikeId = null);
Task PublishStrikeReset(StrikeType strikeType, int strikeCount, string hash, string itemName);
Task PublishQueueItemDeleted(bool removeFromClient, DeleteReason deleteReason);
Task PublishDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason);
@@ -33,11 +33,11 @@ public abstract class ArrClient : IArrClient
_dryRunInterceptor = dryRunInterceptor;
}
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page, long? contentId = null)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/{GetQueueUrlPath().TrimStart('/')}";
uriBuilder.Query = GetQueueUrlQuery(page);
uriBuilder.Query = GetQueueUrlQuery(page, contentId);
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
@@ -271,7 +271,7 @@ public abstract class ArrClient : IArrClient
protected abstract string GetQueueUrlPath();
protected abstract string GetQueueUrlQuery(int page);
protected abstract string GetQueueUrlQuery(int page, long? contentId = null);
protected abstract string GetQueueDeleteUrlPath(long recordId);
@@ -14,7 +14,7 @@ public sealed class ArrQueueIterator : IArrQueueIterator
_logger = logger;
}
public async Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action)
public async Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action, long? contentId = null)
{
const ushort maxPage = 100;
ushort page = 1;
@@ -23,7 +23,7 @@ public sealed class ArrQueueIterator : IArrQueueIterator
do
{
QueueListResponse queueResponse = await arrClient.GetQueueItemsAsync(arrInstance, page);
QueueListResponse queueResponse = await arrClient.GetQueueItemsAsync(arrInstance, page, contentId);
if (totalRecords is 0)
{
@@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IArrClient
{
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page);
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page, long? contentId = null);
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes);
@@ -5,5 +5,5 @@ namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IArrQueueIterator
{
Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action);
Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action, long? contentId = null);
}
@@ -32,7 +32,7 @@ public class LidarrClient : ArrClient, ILidarrClient
return "/api/v1/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownArtistItems=true&includeArtist=true&includeAlbum=true";
}
@@ -32,9 +32,16 @@ public class RadarrClient : ArrClient, IRadarrClient
return "/api/v3/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
string query = $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
if (contentId is not null)
{
query += $"&movieIds={contentId}";
}
return query;
}
protected override string GetQueueDeleteUrlPath(long recordId)
@@ -32,7 +32,7 @@ public class ReadarrClient : ArrClient, IReadarrClient
return "/api/v1/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownAuthorItems=true&includeAuthor=true&includeBook=true";
}
@@ -34,9 +34,16 @@ public class SonarrClient : ArrClient, ISonarrClient
return "/api/v3/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
string query = $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
if (contentId is not null)
{
query += $"&seriesIds={contentId}";
}
return query;
}
protected override string GetQueueDeleteUrlPath(long recordId)
@@ -34,7 +34,7 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
return "/api/v3/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
}
@@ -33,7 +33,7 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
return "/api/v3/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
}
@@ -22,22 +22,7 @@ public sealed record PlexAccountInfo
public interface IPlexAuthService
{
/// <summary>
/// Creates a Plex authentication PIN and builds the URL the user is sent to in order to authorize.
/// </summary>
/// <param name="forwardUrl">
/// Optional URL Plex redirects the browser back to after authorization. When omitted, no redirect
/// is added and the caller is expected to poll <see cref="CheckPin"/> instead.
/// </param>
Task<PlexPinResult> RequestPin(string? forwardUrl = null);
/// <summary>
/// Checks whether a PIN has been authorized, returning the Plex auth token once it has.
/// </summary>
Task<PlexPinResult> RequestPin();
Task<PlexPinCheckResult> CheckPin(int pinId);
/// <summary>
/// Retrieves the Plex account associated with the given auth token.
/// </summary>
Task<PlexAccountInfo> GetAccount(string authToken);
}
@@ -21,7 +21,7 @@ public sealed class PlexAuthService : IPlexAuthService
_clientIdentifier = GetOrCreateClientIdentifier();
}
public async Task<PlexPinResult> RequestPin(string? forwardUrl = null)
public async Task<PlexPinResult> RequestPin()
{
var request = new HttpRequestMessage(HttpMethod.Post, $"{PlexApiBaseUrl}/pins");
AddPlexHeaders(request);
@@ -43,11 +43,6 @@ public sealed class PlexAuthService : IPlexAuthService
var authUrl = $"https://app.plex.tv/auth#?clientID={Uri.EscapeDataString(_clientIdentifier)}&code={Uri.EscapeDataString(pin.Code)}&context%5Bdevice%5D%5Bproduct%5D={Uri.EscapeDataString(PlexProduct)}";
if (!string.IsNullOrEmpty(forwardUrl))
{
authUrl += $"&forwardUrl={Uri.EscapeDataString(forwardUrl)}";
}
return new PlexPinResult
{
PinId = pin.Id,
@@ -5,6 +5,7 @@ using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -154,9 +155,33 @@ public sealed class OrphanedFilesCleanupService : IOrphanedFilesCleanupService
return false;
}
foreach (string claimedPath in await downloadService.GetClaimedPathsAsync(torrents))
foreach (ITorrentItemWrapper torrent in torrents)
{
claimedPaths.Add(claimedPath);
if (string.IsNullOrEmpty(torrent.SavePath))
{
continue;
}
string remappedSavePath = PathHelper.NormalizeAndRemap(
torrent.SavePath,
downloadClient.DownloadDirectorySource,
downloadClient.DownloadDirectoryTarget
).TrimEnd(Path.DirectorySeparatorChar);
claimedPaths.Add(remappedSavePath);
if (string.IsNullOrEmpty(torrent.Name))
{
continue;
}
string contentPath = PathHelper.NormalizeAndRemap(
Path.Combine(torrent.SavePath, torrent.Name),
downloadClient.DownloadDirectorySource,
downloadClient.DownloadDirectoryTarget
);
claimedPaths.Add(contentPath.TrimEnd(Path.DirectorySeparatorChar));
}
_logger.LogDebug("Loaded {count} torrents | {name}", torrents.Count, downloadClient.Name);
@@ -15,6 +15,11 @@ public sealed record BlockFilesResult
public bool IsPrivate { get; set; }
public bool Found { get; set; }
/// <summary>
/// True when the torrent's file list (metadata) was available so the scan could complete (or was not needed).
/// </summary>
public bool MetadataFound { get; set; }
public DeleteReason DeleteReason { get; set; } = DeleteReason.None;
}
@@ -32,15 +32,17 @@ public partial class DelugeService
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
result.MetadataFound = true;
return result;
}
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (malwareBlockerConfig.IgnorePrivate && download.Private)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
result.MetadataFound = true;
return result;
}
@@ -55,11 +57,14 @@ public partial class DelugeService
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
}
if (contents is null)
if (contents is null || contents.Contents?.Count is null or 0)
{
_logger.LogDebug("torrent has no files | {name}", download.Name);
return result;
}
result.MetadataFound = true;
Dictionary<int, int> priorities = [];
bool hasPriorityUpdates = false;
long totalFiles = 0;
@@ -41,27 +41,6 @@ public partial class DelugeService
.ToList();
}
/// <inheritdoc/>
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents) =>
BuildClaimedPathsAsync(torrents, async torrent =>
{
if (string.IsNullOrEmpty(torrent.Hash))
{
return [];
}
DelugeContents? contents = await _client.GetTorrentFiles(torrent.Hash);
List<string> relativePaths = [];
ProcessFiles(contents?.Contents, (_, file) =>
{
if (!string.IsNullOrEmpty(file.Path))
{
relativePaths.Add(file.Path);
}
});
return relativePaths;
});
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
@@ -11,7 +11,6 @@ using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
@@ -78,79 +77,6 @@ public abstract class DownloadService : IDownloadService
/// <inheritdoc/>
public abstract Task<List<ITorrentItemWrapper>> GetAllTorrentsLite();
/// <inheritdoc/>
public abstract Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents);
protected async Task<IReadOnlyList<string>> BuildClaimedPathsAsync(
IReadOnlyList<ITorrentItemWrapper> torrents,
Func<ITorrentItemWrapper, Task<IReadOnlyCollection<string>>> resolveRelativeFilePaths)
{
HashSet<string> claimed = new(StringComparer.OrdinalIgnoreCase);
foreach (ITorrentItemWrapper torrent in torrents)
{
IReadOnlyCollection<string> relativeFilePaths;
try
{
relativeFilePaths = await resolveRelativeFilePaths(torrent);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "failed to resolve files, falling back to name | {name}", torrent.Name);
relativeFilePaths = [];
}
foreach (string path in BuildClaimedPaths(torrent, relativeFilePaths))
{
claimed.Add(path);
}
}
return claimed.ToList();
}
/// <summary>
/// The top-level entries a torrent occupies.
/// </summary>
private IReadOnlyList<string> BuildClaimedPaths(ITorrentItemWrapper torrent, IReadOnlyCollection<string> relativeFilePaths)
{
List<string> claimed = [];
if (string.IsNullOrEmpty(torrent.SavePath))
{
return claimed;
}
claimed.Add(RemapAndTrim(torrent.SavePath));
IReadOnlyCollection<string> sources = relativeFilePaths;
if (sources.Count == 0 && !string.IsNullOrEmpty(torrent.Name))
{
sources = [torrent.Name];
}
foreach (string relativePath in sources)
{
string firstSegment = FirstSegment(relativePath);
if (!string.IsNullOrEmpty(firstSegment))
{
claimed.Add(RemapAndTrim(Path.Combine(torrent.SavePath, firstSegment)));
}
}
return claimed;
}
private static string FirstSegment(string relativePath)
{
string[] parts = relativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
return parts.Length > 0 ? parts[0] : string.Empty;
}
protected string RemapAndTrim(string path) =>
PathHelper
.NormalizeAndRemap(path, _downloadClientConfig.DownloadDirectorySource, _downloadClientConfig.DownloadDirectoryTarget)
.TrimEnd(Path.DirectorySeparatorChar);
/// <inheritdoc/>
public abstract List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules);
@@ -37,12 +37,6 @@ public interface IDownloadService : IDisposable
/// <returns>A list of all torrents.</returns>
Task<List<ITorrentItemWrapper>> GetAllTorrentsLite();
/// <summary>
/// Resolves the on-disk paths claimed by the given torrents.
/// </summary>
/// <returns>The distinct, remapped paths claimed by the torrents.</returns>
Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents);
/// <summary>
/// Filters downloads that should be cleaned.
/// </summary>
@@ -30,6 +30,8 @@ public partial class QBitService
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
result.Found = true;
result.MetadataFound = true;
return result;
}
@@ -55,6 +57,7 @@ public partial class QBitService
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
result.MetadataFound = true;
return result;
}
@@ -66,6 +69,8 @@ public partial class QBitService
return result;
}
result.MetadataFound = true;
List<int> unwantedFiles = [];
long totalFiles = 0;
long totalUnwantedFiles = 0;
@@ -48,19 +48,6 @@ public partial class QBitService
.ToList();
}
/// <inheritdoc/>
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents) =>
BuildClaimedPathsAsync(torrents, async torrent =>
{
if (string.IsNullOrEmpty(torrent.Hash))
{
return [];
}
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(torrent.Hash);
return files?.Select(f => f.Name).Where(name => !string.IsNullOrEmpty(name)).ToList() ?? [];
});
/// <inheritdoc/>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
@@ -36,6 +36,7 @@ public partial class RTorrentService
if (ignoredDownloads.Count > 0 && torrentWrapper.IsIgnored(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
result.MetadataFound = true;
return result;
}
@@ -44,6 +45,7 @@ public partial class RTorrentService
if (malwareBlockerConfig.IgnorePrivate && download.IsPrivate == 1)
{
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
result.MetadataFound = true;
return result;
}
@@ -64,6 +66,8 @@ public partial class RTorrentService
return result;
}
result.MetadataFound = true;
bool hasPriorityUpdates = false;
long totalFiles = 0;
long totalUnwantedFiles = 0;
@@ -32,32 +32,6 @@ public partial class RTorrentService
.ToList();
}
/// <inheritdoc/>
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents)
{
HashSet<string> claimed = new(StringComparer.OrdinalIgnoreCase);
foreach (ITorrentItemWrapper torrent in torrents)
{
if (torrent is not RTorrentItemWrapper wrapper)
{
continue;
}
if (!string.IsNullOrEmpty(wrapper.Info.BasePath))
{
claimed.Add(RemapAndTrim(wrapper.Info.BasePath));
}
if (!string.IsNullOrEmpty(wrapper.Info.Directory))
{
claimed.Add(RemapAndTrim(wrapper.Info.Directory));
}
}
return Task.FromResult<IReadOnlyList<string>>(claimed.ToList());
}
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
@@ -17,27 +17,30 @@ public partial class TransmissionService
TorrentInfo? download = await GetTorrentAsync(hash);
BlockFilesResult result = new();
if (download?.FileStats is null || download.FileStats.Length == 0)
if (download is null)
{
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
if (download.Files is null)
bool isPrivate = download.IsPrivate ?? false;
result.IsPrivate = isPrivate;
result.Found = true;
if (download.FileStats?.Length is null or 0 || download.Files?.Length is null or 0)
{
_logger.LogDebug("torrent {hash} has no files", hash);
_logger.LogDebug("torrent has no files | {name}", download.Name);
return result;
}
result.MetadataFound = true;
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
return result;
}
bool isPrivate = download.IsPrivate ?? false;
result.IsPrivate = isPrivate;
result.Found = true;
SetDownloadClientContext();
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
@@ -48,7 +51,7 @@ public partial class TransmissionService
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
return result;
}
List<long> unwantedFiles = [];
long totalFiles = 0;
long totalUnwantedFiles = 0;
@@ -31,19 +31,6 @@ public partial class TransmissionService
.ToList() ?? [];
}
/// <inheritdoc/>
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents) =>
BuildClaimedPathsAsync(torrents, torrent =>
{
IReadOnlyCollection<string> files = torrent is TransmissionItemWrapper { Info.Files.Length: > 0 } wrapper
? wrapper.Info.Files
.Select(f => f.Name)
.Where(name => !string.IsNullOrEmpty(name))
.ToList()
: [];
return Task.FromResult(files);
});
/// <inheritdoc/>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules)
{
@@ -35,15 +35,17 @@ public partial class UTorrentService
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
result.MetadataFound = true;
return result;
}
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (malwareBlockerConfig.IgnorePrivate && result.IsPrivate)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
result.MetadataFound = true;
return result;
}
@@ -55,6 +57,8 @@ public partial class UTorrentService
return result;
}
result.MetadataFound = true;
List<int> fileIndexes = new(files.Count);
long totalUnwantedFiles = 0;
@@ -36,19 +36,6 @@ public partial class UTorrentService
.ToList();
}
/// <inheritdoc/>
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents) =>
BuildClaimedPathsAsync(torrents, async torrent =>
{
if (string.IsNullOrEmpty(torrent.Hash))
{
return [];
}
List<UTorrentFile>? files = await _client.GetTorrentFilesAsync(torrent.Hash);
return files?.Select(f => f.Name).Where(name => !string.IsNullOrEmpty(name)).ToList() ?? [];
});
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
@@ -106,18 +106,12 @@ public sealed class Striker : IStriker
.Where(s => s.DownloadItemId == downloadItem.Id && s.Type == strikeType)
.ToListAsync();
if (strikesToDelete.Count is 0)
if (strikesToDelete.Count > 0)
{
return;
_context.Strikes.RemoveRange(strikesToDelete);
await _context.SaveChangesAsync();
_logger.LogTrace("Progress detected | resetting {reason} strikes from {strikeCount} to 0 | {name}", strikeType, strikesToDelete.Count, itemName);
}
int resetCount = strikesToDelete.Count;
_context.Strikes.RemoveRange(strikesToDelete);
await _context.SaveChangesAsync();
_logger.LogTrace("Progress detected | resetting {reason} strikes from {strikeCount} to 0 | {name}", strikeType, resetCount, itemName);
await _eventPublisher.PublishStrikeReset(strikeType, resetCount, hash, itemName);
}
private async Task<DownloadItem> GetOrCreateDownloadItemAsync(string hash, string itemName)
@@ -87,8 +87,9 @@ public sealed class DownloadCleaner : GenericHandler
{
DownloadCleanerConfig config = ContextProvider.Get<DownloadCleanerConfig>();
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(config.IgnoredDownloads);
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads
.Concat(config.IgnoredDownloads)
.ToList();
Dictionary<IDownloadService, List<ITorrentItemWrapper>> downloadServiceToDownloadsMap = new();
List<IDownloadService> loggedInServices = new();
@@ -186,11 +186,7 @@ public abstract class GenericHandler : IHandler
_logger.LogInformation("item marked for removal | {title} | {url}", record.Title, instance.Url);
await _eventPublisher.PublishAsync(EventType.DownloadMarkedForDeletion, "Download marked for deletion", EventSeverity.Important,
configure: e =>
{
e.ItemTitle = record.Title;
e.ItemHash = record.DownloadId;
});
data: new { itemName = record.Title, hash = record.DownloadId });
}
protected SearchItem GetRecordSearchItem(InstanceType type, float version, QueueRecord record, bool isPack = false)
@@ -6,6 +6,7 @@ using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
@@ -21,6 +22,7 @@ namespace Cleanuparr.Infrastructure.Features.Jobs;
public sealed class MalwareBlocker : GenericHandler
{
private readonly IBlocklistProvider _blocklistProvider;
private readonly IJobManagementService _jobManagementService;
public MalwareBlocker(
ILogger<MalwareBlocker> logger,
@@ -31,13 +33,15 @@ public sealed class MalwareBlocker : GenericHandler
IArrQueueIterator arrArrQueueIterator,
IDownloadServiceFactory downloadServiceFactory,
IBlocklistProvider blocklistProvider,
IEventPublisher eventPublisher
IEventPublisher eventPublisher,
IJobManagementService jobManagementService
) : base(
logger, dataContext, cache, messageBus,
arrClientFactory, arrArrQueueIterator, downloadServiceFactory, eventPublisher
)
{
_blocklistProvider = blocklistProvider;
_jobManagementService = jobManagementService;
}
protected override async Task ExecuteInternalAsync(CancellationToken cancellationToken = default)
@@ -62,6 +66,12 @@ public sealed class MalwareBlocker : GenericHandler
await _blocklistProvider.LoadBlocklistsAsync();
if (ContextProvider.Get(nameof(WebhookScanTarget)) is WebhookScanTarget webhookTarget)
{
await ProcessWebhookTargetAsync(malwareBlockerConfig, webhookTarget);
return;
}
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
@@ -94,10 +104,52 @@ public sealed class MalwareBlocker : GenericHandler
}
}
protected override async Task ProcessInstanceAsync(ArrInstance instance)
/// <summary>
/// Scans a single download identified by an *arr "On Grab" webhook, restricted to the originating instance, instead of iterating the whole queue.
/// Schedules the next retry only when the download was not yet found/scanned.
/// </summary>
private async Task ProcessWebhookTargetAsync(ContentBlockerConfig config, WebhookScanTarget target)
{
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads);
BlocklistSettings? blocklist = target.Type switch
{
InstanceType.Sonarr => config.Sonarr,
InstanceType.Radarr => config.Radarr,
_ => null,
};
if (blocklist is null || !blocklist.Enabled)
{
_logger.LogDebug("skip webhook scan | blocklist for {type} is not enabled", target.Type);
return;
}
ArrConfig arrConfig = ContextProvider.Get<ArrConfig>(target.Type.ToString());
ArrInstance? instance = arrConfig.Instances
.FirstOrDefault(x => x.Id == target.InstanceId && x.Enabled);
if (instance is null)
{
_logger.LogWarning("skip webhook scan | instance {id} not found or disabled", target.InstanceId);
return;
}
instance.ArrConfig = arrConfig;
bool resolved = await ScanInstanceAsync(instance, target);
if (!resolved)
{
await _jobManagementService.ScheduleMalwareBlockerWebhookRetry(target);
}
}
protected override Task ProcessInstanceAsync(ArrInstance instance) => ScanInstanceAsync(instance);
private async Task<bool> ScanInstanceAsync(ArrInstance instance, WebhookScanTarget? target = null)
{
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads
.Concat(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads)
.ToList();
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
using var _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name);
@@ -111,8 +163,10 @@ public sealed class MalwareBlocker : GenericHandler
ContextProvider.Set(ContextProvider.Keys.Version, instance.Version);
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
var config = ContextProvider.Get<ContentBlockerConfig>();
ContentBlockerConfig config = ContextProvider.Get<ContentBlockerConfig>();
bool targetResolved = false;
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
{
@@ -129,108 +183,148 @@ public sealed class MalwareBlocker : GenericHandler
continue;
}
if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase))
{
_logger.LogInformation("skip | {title} | ignored", record.Title);
continue;
}
_logger.LogTrace("processing | {title} | {id}", record.Title, record.DownloadId);
bool hasContentId = arrClient.HasContentId(record);
if (!hasContentId)
{
if (!config.ProcessNoContentId)
{
_logger.LogInformation("skip | item is missing the content id | {title}", record.Title);
continue;
}
_logger.LogDebug("item is missing the content id | {title}", record.Title);
}
string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
if (_cache.TryGetValue(downloadRemovalKey, out bool _))
{
_logger.LogDebug("skip | already marked for removal | {title}", record.Title);
continue;
}
// push record to context
ContextProvider.Set(nameof(QueueRecord), record);
BlockFilesResult result = new();
bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase);
DownloadClientConfig? foundInClient = null;
if (isTorrent)
{
var torrentClients = downloadServices
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)
.ToList();
_logger.LogDebug("searching unwanted files for {title}", record.Title);
if (torrentClients.Count > 0)
{
// Check each download client for the download item
foreach (var downloadService in torrentClients)
{
try
{
// stalled download check
result = await downloadService
.BlockUnwantedFilesAsync(record.DownloadId, ignoredDownloads);
if (result.Found)
{
foundInClient = downloadService.ClientConfig;
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking download {dName} with download client {cName}",
record.Title, downloadService.ClientConfig.Name);
}
}
if (!result.Found)
{
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
}
}
else
{
_logger.LogDebug("No torrent clients enabled");
}
}
if (!result.ShouldRemove)
if (target is not null &&
!string.Equals(record.DownloadId, target.DownloadId, StringComparison.InvariantCultureIgnoreCase))
{
continue;
}
bool removeFromClient = true;
bool resolved = await TryProcessRecordAsync(group, instance, arrClient, downloadServices, ignoredDownloads, config);
if (result.IsPrivate && !config.DeletePrivate)
if (target is not null)
{
removeFromClient = false;
targetResolved = resolved;
}
await PublishQueueItemRemoveRequest(
downloadRemovalKey,
instance,
record,
group.Count() > 1,
removeFromClient,
result.DeleteReason,
skipSearch: !hasContentId,
downloadClient: foundInClient
);
}
});
}, contentId: target is { ContentId: > 0 } ? target.ContentId : null);
return targetResolved;
}
/// <summary>
/// Scans a single grouped download. Returns <c>true</c> when the download is resolved — found in a
/// client and evaluated, a usenet record, or deliberately skipped — and <c>false</c> only when it is
/// a torrent that was not found in any download client yet (the case a webhook scan should retry).
/// </summary>
private async Task<bool> TryProcessRecordAsync(
IGrouping<string, QueueRecord> group,
ArrInstance instance,
IArrClient arrClient,
IReadOnlyList<IDownloadService> downloadServices,
List<string> ignoredDownloads,
ContentBlockerConfig config)
{
QueueRecord record = group.First();
if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase))
{
_logger.LogInformation("skip | {title} | ignored", record.Title);
return true;
}
_logger.LogTrace("processing | {title} | {id}", record.Title, record.DownloadId);
bool hasContentId = arrClient.HasContentId(record);
if (!hasContentId)
{
if (!config.ProcessNoContentId)
{
_logger.LogInformation("skip | item is missing the content id | {title}", record.Title);
return true;
}
_logger.LogDebug("item is missing the content id | {title}", record.Title);
}
string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
if (_cache.TryGetValue(downloadRemovalKey, out bool _))
{
_logger.LogDebug("skip | already marked for removal | {title}", record.Title);
return true;
}
// push record to context
ContextProvider.Set(nameof(QueueRecord), record);
bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase);
if (!isTorrent)
{
// Usenet is acknowledged once it appears in the queue; nothing to scan, no retry.
return true;
}
BlockFilesResult result = new();
DownloadClientConfig? foundInClient = null;
var torrentClients = downloadServices
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)
.ToList();
_logger.LogDebug("searching unwanted files for {title}", record.Title);
if (torrentClients.Count > 0)
{
// Check each download client for the download item
foreach (var downloadService in torrentClients)
{
try
{
result = await downloadService
.BlockUnwantedFilesAsync(record.DownloadId, ignoredDownloads);
if (result.Found)
{
foundInClient = downloadService.ClientConfig;
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking download {dName} with download client {cName}",
record.Title, downloadService.ClientConfig.Name);
}
}
if (!result.Found)
{
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
}
}
else
{
_logger.LogDebug("No torrent clients enabled");
}
if (!result.Found || !result.MetadataFound)
{
// Retry while the torrent is not yet in a client, or is present but its file list/metadata isn't ready.
return false;
}
if (result.ShouldRemove)
{
bool removeFromClient = true;
if (result.IsPrivate && !config.DeletePrivate)
{
removeFromClient = false;
}
await PublishQueueItemRemoveRequest(
downloadRemovalKey,
instance,
record,
group.Count() > 1,
removeFromClient,
result.DeleteReason,
skipSearch: !hasContentId,
downloadClient: foundInClient
);
}
return true;
}
}
@@ -81,9 +81,10 @@ public sealed class QueueCleaner : GenericHandler
protected override async Task ProcessInstanceAsync(ArrInstance instance)
{
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
QueueCleanerConfig queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
ignoredDownloads.AddRange(queueCleanerConfig.IgnoredDownloads);
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads
.Concat(queueCleanerConfig.IgnoredDownloads)
.ToList();
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
using var _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name);
@@ -0,0 +1,15 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.Jobs;
/// <summary>
/// Identifies a single download that should be scanned by the MalwareBlocker as the result of an *arr "On Grab" webhook.
/// </summary>
public sealed record WebhookScanTarget(Guid InstanceId, string DownloadId, long ContentId, InstanceType Type, int RetryIndex = 0)
{
public const string InstanceIdKey = "webhook.instanceId";
public const string DownloadIdKey = "webhook.downloadId";
public const string ContentIdKey = "webhook.contentId";
public const string InstanceTypeKey = "webhook.instanceType";
public const string RetryIndexKey = "webhook.retryIndex";
}
@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Models;
using Quartz;
@@ -9,8 +10,19 @@ public interface IJobManagementService
Task<bool> StartJob(JobType jobType, JobSchedule? schedule = null, string? directCronExpression = null);
Task<bool> StopJob(JobType jobType);
Task<bool> TriggerJobOnce(JobType jobType);
/// <summary>
/// Schedules the first targeted MalwareBlocker scan for a single download received via an *arr "On Grab" webhook.
/// Subsequent retries are scheduled by the handler via <see cref="ScheduleMalwareBlockerWebhookRetry"/> only while the download has not been found.
/// </summary>
Task<bool> TriggerMalwareBlockerWebhook(Guid instanceId, string downloadId, long contentId, InstanceType type);
/// <summary>
/// Schedules the next targeted MalwareBlocker webhook scan after a completed attempt.
/// </summary>
Task<bool> ScheduleMalwareBlockerWebhookRetry(WebhookScanTarget target);
Task<IReadOnlyList<JobInfo>> GetAllJobs(IScheduler? scheduler = null);
Task<JobInfo> GetJob(JobType jobType);
Task<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule);
Task<ITrigger?> GetMainTrigger(JobType jobType);
}
@@ -1,8 +1,10 @@
using System.Collections.Concurrent;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Quartz;
@@ -168,34 +170,6 @@ public class JobManagementService : IJobManagementService
_logger.LogWarning(ex, "Failed to trigger job {jobName} immediately", jobKey.Name);
}
}
/// <summary>
/// Gets the main scheduled trigger for a job (excludes one-time triggers)
/// </summary>
public async Task<ITrigger?> GetMainTrigger(JobType jobType)
{
string jobName = jobType.ToString();
try
{
var scheduler = await _schedulerFactory.GetScheduler();
var jobKey = new JobKey(jobName);
if (!await scheduler.CheckExists(jobKey))
{
return null;
}
// Look for the main trigger (follows our naming convention)
var mainTriggerKey = new TriggerKey($"{jobName}-trigger");
return await scheduler.GetTrigger(mainTriggerKey);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting main trigger for job {jobName}", jobName);
return null;
}
}
public async Task<bool> StopJob(JobType jobType)
{
@@ -368,6 +342,71 @@ public class JobManagementService : IJobManagementService
}
}
public Task<bool> TriggerMalwareBlockerWebhook(Guid instanceId, string downloadId, long contentId, InstanceType type)
{
return ScheduleWebhookAttempt(instanceId, downloadId, contentId, type, attemptIndex: 0);
}
public Task<bool> ScheduleMalwareBlockerWebhookRetry(WebhookScanTarget target)
{
int nextIndex = target.RetryIndex + 1;
if (nextIndex >= Constants.MalwareBlockerWebhookRetryDelays.Count)
{
_logger.LogDebug(
"{name} webhook scan gave up for download {downloadId} on {type} instance {instanceId} after {attemptCount} attempts",
nameof(JobType.MalwareBlocker), target.DownloadId, target.Type, target.InstanceId, Constants.MalwareBlockerWebhookRetryDelays.Count);
return Task.FromResult(false);
}
return ScheduleWebhookAttempt(target.InstanceId, target.DownloadId, target.ContentId, target.Type, nextIndex);
}
private async Task<bool> ScheduleWebhookAttempt(Guid instanceId, string downloadId, long contentId, InstanceType type, int attemptIndex)
{
try
{
var scheduler = await _schedulerFactory.GetScheduler();
var jobKey = new JobKey(Constants.MalwareBlockerWebhookJobKey);
if (!await scheduler.CheckExists(jobKey))
{
_logger.LogError("Job {name} does not exist", Constants.MalwareBlockerWebhookJobKey);
return false;
}
TimeSpan delay = Constants.MalwareBlockerWebhookRetryDelays[attemptIndex];
var jobData = new JobDataMap
{
{ WebhookScanTarget.InstanceIdKey, instanceId.ToString() },
{ WebhookScanTarget.DownloadIdKey, downloadId },
{ WebhookScanTarget.ContentIdKey, contentId },
{ WebhookScanTarget.InstanceTypeKey, type.ToString() },
{ WebhookScanTarget.RetryIndexKey, attemptIndex },
};
var trigger = TriggerBuilder.Create()
.WithIdentity($"{Constants.MalwareBlockerWebhookJobKey}-{instanceId}-{downloadId}-{attemptIndex}-{DateTimeOffset.UtcNow.Ticks}")
.ForJob(jobKey)
.UsingJobData(jobData)
.StartAt(DateTimeOffset.UtcNow.Add(delay))
.Build();
await scheduler.ScheduleJob(trigger);
_logger.LogInformation(
"MalwareBlocker webhook scan attempt {attempt} scheduled (in {delay}s) for download {downloadId} on {type} instance {instanceId}",
attemptIndex, (int)delay.TotalSeconds, downloadId, type, instanceId);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error scheduling MalwareBlocker webhook scan for instance {instanceId}", instanceId);
return false;
}
}
public async Task<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule)
{
if (schedule == null)
@@ -1,37 +0,0 @@
namespace Cleanuparr.Infrastructure.Stats;
/// <summary>
/// Cached health snapshot for a single arr instance.
/// </summary>
public class ArrInstanceHealthDto
{
/// <summary>
/// Unique identifier of the arr instance.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Display name of the arr instance.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Instance type (Sonarr, Radarr, Lidarr, Readarr, Whisparr).
/// </summary>
public string Type { get; set; } = string.Empty;
/// <summary>
/// Whether the last health check succeeded.
/// </summary>
public bool IsHealthy { get; set; }
/// <summary>
/// When the last health check ran (UTC).
/// </summary>
public DateTimeOffset LastChecked { get; set; }
/// <summary>
/// Error message from the last health check, or null when healthy.
/// </summary>
public string? ErrorMessage { get; set; }
}
@@ -1,20 +0,0 @@
namespace Cleanuparr.Infrastructure.Stats;
/// <summary>
/// Downloads cleaned by the download cleaner in the timeframe (DownloadCleaned events), broken down by reason.
/// Cleaning is distinct from a removal: it happens when a download meets its seeding goals, not because it
/// was struck out. Excludes dry-run activity unless the caller opts in.
/// </summary>
public class CleanedV2Stats
{
/// <summary>
/// Total downloads cleaned in the timeframe (all reasons). Equal to the sum of <see cref="ByReason"/>.
/// </summary>
public int Total { get; set; }
/// <summary>
/// Cleaned downloads grouped by clean reason (MaxRatioReached, MaxSeedTimeReached).
/// Keys are PascalCase clean-reason names; only reasons with activity are present.
/// </summary>
public Dictionary<string, int> ByReason { get; set; } = new();
}
@@ -1,42 +0,0 @@
namespace Cleanuparr.Infrastructure.Stats;
/// <summary>
/// Cached health snapshot for a single download client.
/// </summary>
public class DownloadClientHealthDto
{
/// <summary>
/// Unique identifier of the download client.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Display name of the download client.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Client type (qBittorrent, Transmission, Deluge, ...).
/// </summary>
public string Type { get; set; } = string.Empty;
/// <summary>
/// Whether the last health check succeeded.
/// </summary>
public bool IsHealthy { get; set; }
/// <summary>
/// When the last health check ran (UTC).
/// </summary>
public DateTimeOffset LastChecked { get; set; }
/// <summary>
/// Response time of the last health check in milliseconds, or null if unavailable.
/// </summary>
public double? ResponseTimeMs { get; set; }
/// <summary>
/// Error message from the last health check, or null when healthy.
/// </summary>
public string? ErrorMessage { get; set; }
}
@@ -1,17 +0,0 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Infrastructure.Stats;
public class EventStats
{
public int TotalCount { get; set; }
public Dictionary<string, int> ByType { get; set; } = new();
public Dictionary<string, int> BySeverity { get; set; } = new();
public int TimeframeHours { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<RecentEventDto>? RecentItems { get; set; }
}
@@ -1,22 +0,0 @@
namespace Cleanuparr.Infrastructure.Stats;
/// <summary>
/// Raw event audit for the timeframe. Excludes dry-run events unless the caller opts in.
/// </summary>
public class EventV2Stats
{
/// <summary>
/// Total number of events in the timeframe. Equal to the sum of <see cref="ByType"/>.
/// </summary>
public int Total { get; set; }
/// <summary>
/// Events grouped by event type. Keys are PascalCase event-type names; only types with activity are present.
/// </summary>
public Dictionary<string, int> ByType { get; set; } = new();
/// <summary>
/// Events grouped by severity (Information, Warning, Important, Error). Only severities with activity are present.
/// </summary>
public Dictionary<string, int> BySeverity { get; set; } = new();
}
Loaded 100 of 309 files, more files were not shown because too many files have changed in this diff. Show more