mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-09 11:59:02 -04:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad8c5f23cf | ||
|
|
9eebeed990 | ||
|
|
a79a60a339 | ||
|
|
d1bd9fddcc | ||
|
|
96823adcc3 | ||
|
|
e0e88147aa | ||
|
|
f278a0dad0 | ||
|
|
f61300b869 | ||
|
|
561c05778c | ||
|
|
60d273991d | ||
|
|
ddb1042ca5 | ||
|
|
9a31e86ad8 | ||
|
|
614e97313e | ||
|
|
a34a3d3c7e | ||
|
|
f9588d89c0 | ||
|
|
eacd9346a5 | ||
|
|
0561c64ddf | ||
|
|
304a8e78ee | ||
|
|
e008b64a1d | ||
|
|
4f7e2d33b4 | ||
|
|
b1b19e5f29 | ||
|
|
40ab0e9fad |
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
-8
@@ -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();
|
||||
}
|
||||
-8
@@ -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; } = [];
|
||||
}
|
||||
-13
@@ -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();
|
||||
}
|
||||
|
||||
+4
@@ -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;
|
||||
|
||||
+5
-1
@@ -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();
|
||||
|
||||
-17
@@ -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]
|
||||
|
||||
-36
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-67
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-28
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-32
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-29
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-23
@@ -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()
|
||||
{
|
||||
|
||||
+20
-11
@@ -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)
|
||||
|
||||
+27
-11
@@ -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);
|
||||
|
||||
+31
-14
@@ -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);
|
||||
}
|
||||
|
||||
+20
-10
@@ -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();
|
||||
|
||||
+64
-50
@@ -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
|
||||
|
||||
+4
@@ -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();
|
||||
|
||||
-1
@@ -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,
|
||||
|
||||
+27
-2
@@ -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;
|
||||
}
|
||||
+9
-4
@@ -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;
|
||||
|
||||
-21
@@ -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>
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
-13
@@ -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
|
||||
|
||||
+4
@@ -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;
|
||||
|
||||
-26
@@ -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))))
|
||||
|
||||
+10
-7
@@ -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;
|
||||
|
||||
-13
@@ -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)
|
||||
{
|
||||
|
||||
+5
-1
@@ -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;
|
||||
|
||||
|
||||
-13
@@ -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";
|
||||
}
|
||||
+13
-1
@@ -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
Reference in new issue
Block a user