Compare commits

...
270 changed files with 20576 additions and 27707 deletions

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
node-version: '26'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
@@ -1,9 +1,12 @@
name: Deploy to Cloudflare Pages
on:
push:
tags:
- "v*.*.*"
workflow_call:
inputs:
version:
description: 'Release version (e.g. v1.2.3)'
type: string
required: true
jobs:
deploy:
@@ -14,7 +17,7 @@ jobs:
- name: Create status files
run: |
mkdir -p status
echo "{ \"version\": \"${GITHUB_REF_NAME}\" }" > status/status.json
echo "{ \"version\": \"${{ inputs.version }}\" }" > status/status.json
# Cache static files for 10 minutes
cat > status/_headers << 'EOF'
+2 -4
View File
@@ -1,9 +1,7 @@
name: Deploy Docusaurus to GitHub Pages
on:
push:
tags:
- "v*.*.*"
workflow_call: {}
workflow_dispatch: {}
permissions:
@@ -27,7 +25,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 26.x
cache: yarn
cache-dependency-path: docs/yarn.lock
+4 -2
View File
@@ -24,6 +24,7 @@ permissions:
jobs:
e2e:
if: false
runs-on: ubuntu-latest
timeout-minutes: 20
@@ -63,7 +64,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 26
- name: Install E2E dependencies
working-directory: e2e
@@ -71,7 +72,8 @@ jobs:
- name: Install Playwright browsers
working-directory: e2e
run: npx playwright install --with-deps chromium
timeout-minutes: 5
run: npx playwright install chromium
- name: Wait for Keycloak
run: |
+22 -1
View File
@@ -99,7 +99,8 @@ jobs:
# Run E2E tests
e2e:
needs: validate
if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
# if: ${{ needs.validate.outputs.is_tag == 'true' || github.event.inputs.runTests == 'true' }}
if: false
uses: ./.github/workflows/e2e.yml
secrets: inherit
@@ -242,6 +243,26 @@ 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]
+11 -5
View File
@@ -46,11 +46,13 @@ 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 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
- **Angular 22** with TypeScript 6.0, Node 26 (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**: @ngrx/signals (Angular signals-based)
- **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)
- **Real-time Updates**: @microsoft/signalr 10.0.0
- **PWA**: Service Worker support enabled
@@ -69,7 +71,7 @@ Cleanuparr/
│ │ ├── Cleanuparr.Persistence/ # SQLite data access
│ │ ├── Cleanuparr.Persistence.Tests/
│ │ └── Cleanuparr.Shared/ # Shared utilities
│ ├── frontend/ # Angular 21 application
│ ├── frontend/ # Angular 22 application
│ ├── e2e/ # Playwright E2E tests
│ ├── Dockerfile # Multi-stage Docker build
│ ├── entrypoint.sh # Docker entrypoint
@@ -98,6 +100,8 @@ 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`
@@ -183,7 +187,9 @@ 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 21)
- Use `@ng-icons/core` + `@ng-icons/tabler-icons` for icons (NOT `angular-tabler-icons` which doesn't support Angular 22)
- **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()`)
- **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)
+1 -1
View File
@@ -27,7 +27,7 @@ This helps us avoid redundant work, git conflicts, and contributions that may no
### Prerequisites
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- [Node.js 18+](https://nodejs.org/)
- [Node.js 26+](https://nodejs.org/)
- [Git](https://git-scm.com/)
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations
- (Optional) IDE: [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio](https://visualstudio.microsoft.com/)
+1 -1
View File
@@ -1,5 +1,5 @@
# Build Angular frontend
FROM --platform=$BUILDPLATFORM node:25-alpine AS frontend-build
FROM --platform=$BUILDPLATFORM node:26-alpine AS frontend-build
WORKDIR /app
# Copy package files first for better layer caching
@@ -0,0 +1,101 @@
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");
}
}
@@ -0,0 +1,134 @@
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);
}
}
@@ -0,0 +1,66 @@
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,21 +279,16 @@ public class SearchStatsControllerTests : IDisposable
Timestamp = timestamp ?? DateTime.UtcNow
};
_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();
appEvent.ItemTitle = itemTitle;
appEvent.SearchType = searchType;
appEvent.SearchReason = searchReason;
appEvent.GrabbedItems = grabbedItems ?? [];
}
_eventsContext.Events.Add(appEvent);
_eventsContext.SaveChanges();
}
#endregion
@@ -0,0 +1,10 @@
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);
}
@@ -0,0 +1,22 @@
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,5 +1,9 @@
using System.Text.Json.Serialization;
using System.Globalization;
using Cleanuparr.Api.Common;
using Cleanuparr.Api.Contracts.Responses;
using Cleanuparr.Api.Features.Events.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Stats;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
using Microsoft.AspNetCore.Authorization;
@@ -24,7 +28,7 @@ public class EventsController : ControllerBase
/// Gets events with pagination and filtering
/// </summary>
[HttpGet]
public async Task<ActionResult<PaginatedResult<AppEvent>>> GetEvents(
public async Task<ActionResult<PaginatedResult<EventListItem>>> GetEvents(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50,
[FromQuery] string? severity = null,
@@ -49,35 +53,40 @@ public class EventsController : ControllerBase
{
pageSize = 500;
}
var query = _context.Events.AsQueryable();
IQueryable<EventListItem> query = _context.Events
.Select(EventListItem.FromEvent);
// Apply filters
if (!string.IsNullOrWhiteSpace(severity))
{
if (Enum.TryParse<EventSeverity>(severity, true, out var severityEnum))
if (Enum.TryParse<EventSeverity>(severity, true, out EventSeverity severityEnum))
{
query = query.Where(e => e.Severity == severityEnum);
}
}
if (!string.IsNullOrWhiteSpace(eventType))
{
if (Enum.TryParse<EventType>(eventType, true, out var eventTypeEnum))
if (Enum.TryParse<EventType>(eventType, true, out EventType 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 var jobRunGuid))
if (!string.IsNullOrWhiteSpace(jobRunId) && Guid.TryParse(jobRunId, out Guid jobRunGuid))
{
query = query.Where(e => e.JobRunId == jobRunGuid);
}
@@ -88,28 +97,28 @@ public class EventsController : ControllerBase
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
EF.Functions.Like(e.Data, pattern) ||
(e.ItemTitle != null && EF.Functions.Like(e.ItemTitle, pattern)) ||
EF.Functions.Like(e.TrackingId.ToString(), pattern) ||
EF.Functions.Like(e.JobRunId.ToString(), pattern)
);
}
// Count total matching records for pagination
var totalCount = await query.CountAsync();
int totalCount = await query.CountAsync();
// Calculate pagination
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var skip = (page - 1) * pageSize;
// Get paginated data
var events = await query
int totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
int skip = (page - 1) * pageSize;
List<EventListItem> events = await query
.OrderByDescending(e => e.Timestamp)
.ThenByDescending(e => e.Id)
.Skip(skip)
.Take(pageSize)
.ToListAsync();
// Return paginated result
var result = new PaginatedResult<AppEvent>
PaginatedResult<EventListItem> result = new()
{
Items = events,
Page = page,
@@ -117,7 +126,7 @@ public class EventsController : ControllerBase
TotalCount = totalCount,
TotalPages = totalPages
};
return Ok(result);
}
@@ -128,7 +137,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();
@@ -149,21 +158,6 @@ 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>
@@ -183,48 +177,68 @@ public class EventsController : ControllerBase
var severities = Enum.GetNames(typeof(EventSeverity)).ToList();
return Ok(severities);
}
}
/// <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;
}
[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; }
}
}
@@ -1,3 +1,4 @@
using Cleanuparr.Api.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Events;
@@ -79,7 +80,7 @@ public class ManualEventsController : ControllerBase
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e =>
EF.Functions.Like(e.Message, pattern) ||
EF.Functions.Like(e.Data, pattern)
(e.ItemTitle != null && EF.Functions.Like(e.ItemTitle, pattern))
);
}
@@ -136,11 +137,28 @@ 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>
@@ -175,19 +193,4 @@ 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,13 +5,16 @@ using Microsoft.AspNetCore.Mvc;
namespace Cleanuparr.Api.Controllers;
/// <summary>
/// Aggregated statistics endpoint for dashboard integrations
/// Aggregated statistics endpoint for dashboard integrations.
/// Deprecated. Use <c>GET /api/v2/stats</c> instead.
/// </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)
@@ -20,7 +23,8 @@ public class StatsController : ControllerBase
}
/// <summary>
/// Gets aggregated application statistics for the specified timeframe
/// Gets aggregated application statistics for the specified timeframe.
/// Deprecated. Use <c>GET /api/v2/stats</c> instead. Responses carry Deprecation/Link headers.
/// </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>
@@ -31,6 +35,17 @@ 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);
@@ -0,0 +1,64 @@
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,3 +1,5 @@
using Cleanuparr.Api.Contracts.Responses;
using Cleanuparr.Api.Features.Strikes.Contracts.Responses;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.State;
@@ -170,39 +172,3 @@ 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,6 +28,7 @@ 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,
@@ -37,7 +38,8 @@ public sealed class AuthController : ControllerBase
ITotpService totpService,
IPlexAuthService plexAuthService,
IOidcAuthService oidcAuthService,
ILogger<AuthController> logger)
ILogger<AuthController> logger,
IWebHostEnvironment environment)
{
_usersContext = usersContext;
_dataContext = dataContext;
@@ -47,6 +49,7 @@ public sealed class AuthController : ControllerBase
_plexAuthService = plexAuthService;
_oidcAuthService = oidcAuthService;
_logger = logger;
_environment = environment;
}
[HttpGet("status")]
@@ -497,7 +500,17 @@ public sealed class AuthController : ControllerBase
return this.ProblemResult(StatusCodes.Status400BadRequest, "Plex login is not available");
}
var pin = await _plexAuthService.RequestPin();
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);
return Ok(new PlexPinStatusResponse
{
@@ -0,0 +1,73 @@
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,
};
}
@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Features.Events.Contracts.Responses;
public sealed record EventTypeTimelineBucket
{
public DateTimeOffset Date { get; init; }
public Dictionary<string, int> Counts { get; init; } = new();
}
@@ -0,0 +1,8 @@
namespace Cleanuparr.Api.Features.Events.Contracts.Responses;
public sealed record EventTypeTimelineResponse
{
public List<string> Types { get; init; } = [];
public List<EventTypeTimelineBucket> Buckets { get; init; } = [];
}
@@ -27,6 +27,8 @@ 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();
@@ -42,6 +44,7 @@ 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);
@@ -70,6 +73,16 @@ 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();
}
@@ -164,7 +164,6 @@ public sealed class SearchStatsController : ControllerBase
var query = _eventsContext.Events
.AsNoTracking()
.Include(e => e.SearchEventData)
.Where(e => e.EventType == EventType.SearchTriggered);
// Filter by instance ID
@@ -179,12 +178,12 @@ public sealed class SearchStatsController : ControllerBase
query = query.Where(e => e.CycleId == cycleId.Value);
}
// Search by item title in SearchEventData
// Search by item title
if (!string.IsNullOrWhiteSpace(search))
{
string pattern = EventsContext.GetLikePattern(search);
query = query.Where(e => e.SearchEventData != null
&& EF.Functions.Like(e.SearchEventData.ItemTitle, pattern));
query = query.Where(e => e.ItemTitle != null
&& EF.Functions.Like(e.ItemTitle, pattern));
}
// Filter by search status (multi-valued)
@@ -197,13 +196,13 @@ public sealed class SearchStatsController : ControllerBase
if (searchType.HasValue)
{
SeekerSearchType typeValue = searchType.Value;
query = query.Where(e => e.SearchEventData != null && e.SearchEventData.SearchType == typeValue);
query = query.Where(e => e.SearchType == typeValue);
}
if (searchReason.HasValue)
{
SeekerSearchReason reasonValue = searchReason.Value;
query = query.Where(e => e.SearchEventData != null && e.SearchEventData.SearchReason == reasonValue);
query = query.Where(e => e.SearchReason == reasonValue);
}
// Filter by grabbed-result presence
@@ -211,11 +210,11 @@ public sealed class SearchStatsController : ControllerBase
{
if (grabbed.Value)
{
query = query.Where(e => e.SearchEventData != null && e.SearchEventData.GrabbedItems.Count > 0);
query = query.Where(e => e.GrabbedItems.Count > 0);
}
else
{
query = query.Where(e => e.SearchEventData == null || e.SearchEventData.GrabbedItems.Count == 0);
query = query.Where(e => e.GrabbedItems.Count == 0);
}
}
@@ -226,14 +225,14 @@ public sealed class SearchStatsController : ControllerBase
IOrderedQueryable<AppEvent> ordered = sortBy switch
{
SearchEventsSortBy.Title => ascending
? query.OrderBy(e => e.SearchEventData != null ? e.SearchEventData.ItemTitle : string.Empty)
: query.OrderByDescending(e => e.SearchEventData != null ? e.SearchEventData.ItemTitle : string.Empty),
? query.OrderBy(e => e.ItemTitle ?? string.Empty)
: query.OrderByDescending(e => e.ItemTitle ?? string.Empty),
SearchEventsSortBy.Status => ascending
? query.OrderBy(e => e.SearchStatus)
: query.OrderByDescending(e => e.SearchStatus),
SearchEventsSortBy.Type => ascending
? query.OrderBy(e => e.SearchEventData != null ? (int)e.SearchEventData.SearchType : 0)
: query.OrderByDescending(e => e.SearchEventData != null ? (int)e.SearchEventData.SearchType : 0),
? query.OrderBy(e => e.SearchType)
: query.OrderByDescending(e => e.SearchType),
_ => ascending
? query.OrderBy(e => e.Timestamp)
: query.OrderByDescending(e => e.Timestamp),
@@ -273,12 +272,12 @@ public sealed class SearchStatsController : ControllerBase
InstanceType = e.ArrInstanceId.HasValue && instanceTypeMap.TryGetValue(e.ArrInstanceId.Value, out var it)
? it.ToString()
: null,
ItemTitle = e.SearchEventData?.ItemTitle ?? "Unknown",
SearchType = e.SearchEventData?.SearchType ?? SeekerSearchType.Proactive,
SearchReason = e.SearchEventData?.SearchReason,
ItemTitle = e.ItemTitle ?? "Unknown",
SearchType = e.SearchType ?? SeekerSearchType.Proactive,
SearchReason = e.SearchReason,
SearchStatus = e.SearchStatus,
CompletedAt = e.CompletedAt,
GrabbedItems = e.SearchEventData?.GrabbedItems ?? [],
GrabbedItems = e.GrabbedItems,
CycleId = e.CycleId,
IsDryRun = e.IsDryRun,
}).ToList();
@@ -0,0 +1,17 @@
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; } = [];
}
@@ -0,0 +1,11 @@
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; }
}
@@ -0,0 +1,11 @@
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; }
}
@@ -13,4 +13,5 @@ public enum EventType
CategoryChanged,
DownloadMarkedForDeletion,
SearchTriggered,
StrikeReset,
}
@@ -0,0 +1,7 @@
namespace Cleanuparr.Domain.Enums;
public enum ManualEventType
{
RecurringDownload,
SearchNotTriggered,
}
@@ -0,0 +1,9 @@
namespace Cleanuparr.Domain.Enums;
public enum TimelineBucketSize
{
Hour,
Day,
Week,
Month,
}
@@ -0,0 +1,153 @@
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,23 +88,25 @@ public class EventPublisherTests : IDisposable
}
[Fact]
public async Task PublishAsync_WithData_SerializesDataToJson()
public async Task PublishAsync_WithConfigure_PersistsTypedFields()
{
// 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, data);
await _publisher.PublishAsync(eventType, message, severity, configure: e =>
{
e.ItemTitle = "TestDownload";
e.ItemHash = "abc123";
});
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
savedEvent.ShouldNotBeNull();
savedEvent.Data.ShouldNotBeNull();
savedEvent.Data.ShouldContain("TestDownload");
savedEvent.Data.ShouldContain("abc123");
savedEvent.ItemTitle.ShouldBe("TestDownload");
savedEvent.ItemHash.ShouldBe("abc123");
}
[Fact]
@@ -166,7 +168,7 @@ public class EventPublisherTests : IDisposable
}
[Fact]
public async Task PublishAsync_NullData_DoesNotSerialize()
public async Task PublishAsync_NullConfigure_LeavesTypedFieldsUnset()
{
// Arrange
var eventType = EventType.DownloadCleaned;
@@ -174,12 +176,13 @@ public class EventPublisherTests : IDisposable
var severity = EventSeverity.Information;
// Act
await _publisher.PublishAsync(eventType, message, severity, data: null);
await _publisher.PublishAsync(eventType, message, severity, configure: null);
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
savedEvent.ShouldNotBeNull();
savedEvent.Data.ShouldBeNull();
savedEvent.ItemTitle.ShouldBeNull();
savedEvent.ItemHash.ShouldBeNull();
}
#endregion
@@ -194,7 +197,7 @@ public class EventPublisherTests : IDisposable
var severity = EventSeverity.Warning;
// Act
await _publisher.PublishManualAsync(message, severity);
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, message, severity);
// Assert
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
@@ -204,22 +207,24 @@ public class EventPublisherTests : IDisposable
}
[Fact]
public async Task PublishManualAsync_WithData_SerializesDataToJson()
public async Task PublishManualAsync_WithConfigure_PersistsTypedFields()
{
// Arrange
var message = "Manual event";
var severity = EventSeverity.Important;
var data = new { ItemName = "TestItem", Count = 5 };
// Act
await _publisher.PublishManualAsync(message, severity, data);
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, message, severity, configure: e =>
{
e.ItemTitle = "TestItem";
e.StrikeCount = 5;
});
// Assert
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
savedEvent.ShouldNotBeNull();
savedEvent.Data.ShouldNotBeNull();
savedEvent.Data.ShouldContain("TestItem");
savedEvent.Data.ShouldContain("5");
savedEvent.ItemTitle.ShouldBe("TestItem");
savedEvent.StrikeCount.ShouldBe(5);
}
[Fact]
@@ -230,7 +235,7 @@ public class EventPublisherTests : IDisposable
var severity = EventSeverity.Information;
// Act
await _publisher.PublishManualAsync(message, severity);
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, message, severity);
// Assert
await _clientProxy.Received(1).SendCoreAsync(
@@ -241,6 +246,119 @@ 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]
@@ -266,7 +384,7 @@ public class EventPublisherTests : IDisposable
var severity = EventSeverity.Important;
// Act
await _publisher.PublishManualAsync(message, severity);
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, message, severity);
// Assert
await _dryRunInterceptor.Received(1).IsDryRunEnabled();
@@ -316,7 +434,7 @@ public class EventPublisherTests : IDisposable
var severity = EventSeverity.Important;
// Act
await _publisher.PublishManualAsync(message, severity);
await _publisher.PublishManualAsync(ManualEventType.RecurringDownload, message, severity);
// Assert
var savedEvent = await _context.ManualEvents.FirstOrDefaultAsync();
@@ -344,54 +462,6 @@ 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]
@@ -409,10 +479,25 @@ public class EventPublisherTests : IDisposable
savedEvent.ShouldNotBeNull();
savedEvent.EventType.ShouldBe(EventType.QueueItemDeleted);
savedEvent.Severity.ShouldBe(EventSeverity.Important);
savedEvent.Data.ShouldNotBeNull();
savedEvent.Data.ShouldContain("Test Download");
savedEvent.Data.ShouldContain("abc123");
savedEvent.Data.ShouldContain("Stalled");
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);
}
[Fact]
@@ -452,11 +537,12 @@ public class EventPublisherTests : IDisposable
savedEvent.ShouldNotBeNull();
savedEvent.EventType.ShouldBe(EventType.DownloadCleaned);
savedEvent.Severity.ShouldBe(EventSeverity.Important);
savedEvent.Data.ShouldNotBeNull();
savedEvent.Data.ShouldContain("Cleaned Download");
savedEvent.Data.ShouldContain("def456");
savedEvent.Data.ShouldContain("movies");
savedEvent.Data.ShouldContain("MaxSeedTimeReached");
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);
}
[Fact]
@@ -497,9 +583,8 @@ public class EventPublisherTests : IDisposable
savedEvent.ShouldNotBeNull();
savedEvent.Severity.ShouldBe(EventSeverity.Warning);
savedEvent.Message.ShouldContain("Replacement search was not triggered");
savedEvent.Data.ShouldNotBeNull();
savedEvent.Data.ShouldContain("Test Item");
savedEvent.Data.ShouldContain("abc123");
savedEvent.ItemTitle.ShouldBe("Test Item");
savedEvent.ItemHash.ShouldBe("abc123");
}
#endregion
@@ -521,9 +606,9 @@ public class EventPublisherTests : IDisposable
savedEvent.ShouldNotBeNull();
savedEvent.Severity.ShouldBe(EventSeverity.Important);
savedEvent.Message.ShouldContain("keeps coming back");
savedEvent.Data.ShouldNotBeNull();
savedEvent.Data.ShouldContain("Recurring Item");
savedEvent.Data.ShouldContain("hash123");
savedEvent.ItemTitle.ShouldBe("Recurring Item");
savedEvent.ItemHash.ShouldBe("hash123");
savedEvent.StrikeCount.ShouldBe(5);
}
#endregion
@@ -635,7 +720,7 @@ public class EventPublisherTests : IDisposable
}
[Fact]
public async Task PublishSearchTriggered_CreatesSearchEventData()
public async Task PublishSearchTriggered_SetsSearchFields()
{
// Act
await _publisher.PublishSearchTriggered("Series A", SeekerSearchType.Replacement, SeekerSearchReason.Replacement);
@@ -643,12 +728,9 @@ public class EventPublisherTests : IDisposable
// Assert
var savedEvent = await _context.Events.FirstOrDefaultAsync();
savedEvent.ShouldNotBeNull();
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);
savedEvent.ItemTitle.ShouldBe("Series A");
savedEvent.SearchType.ShouldBe(SeekerSearchType.Replacement);
savedEvent.SearchReason.ShouldBe(SeekerSearchReason.Replacement);
}
[Fact]
@@ -722,7 +804,7 @@ public class EventPublisherTests : IDisposable
}
[Fact]
public async Task PublishSearchCompleted_UpdatesGrabbedItemsOnSearchEventData()
public async Task PublishSearchCompleted_UpdatesGrabbedItems()
{
// Arrange
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
@@ -733,13 +815,13 @@ public class EventPublisherTests : IDisposable
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, InstanceType.Radarr, "http://localhost:7878", grabbedItems);
// Assert
var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == eventId);
searchData.ShouldNotBeNull();
searchData.GrabbedItems.ShouldContain("Movie A (2024)");
var updatedEvent = await _context.Events.FindAsync(eventId);
updatedEvent.ShouldNotBeNull();
updatedEvent.GrabbedItems.ShouldContain("Movie A (2024)");
}
[Fact]
public async Task PublishSearchCompleted_WithNullGrabbedItems_DoesNotModifySearchEventData()
public async Task PublishSearchCompleted_WithNullGrabbedItems_LeavesGrabbedItemsEmpty()
{
// Arrange
Guid eventId = await _publisher.PublishSearchTriggered("Movie A", SeekerSearchType.Proactive, SeekerSearchReason.Missing);
@@ -748,9 +830,9 @@ public class EventPublisherTests : IDisposable
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, InstanceType.Radarr, "http://localhost:7878");
// Assert
var searchData = await _context.SearchEventData.FirstOrDefaultAsync(s => s.AppEventId == eventId);
searchData.ShouldNotBeNull();
searchData.GrabbedItems.ShouldBeEmpty();
var updatedEvent = await _context.Events.FindAsync(eventId);
updatedEvent.ShouldNotBeNull();
updatedEvent.GrabbedItems.ShouldBeEmpty();
}
[Fact]
@@ -847,4 +847,40 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
.SetTorrentLabel("hash1", "unlinked");
}
}
public class GetClaimedPaths_Tests : DelugeServiceDCTests
{
public GetClaimedPaths_Tests(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task DerivesRootFromFetchedFiles_SharedFolderDedupes()
{
var sut = _fixture.CreateSut();
var wrapper = new DelugeItemWrapper(new DownloadStatus
{
Hash = "hash1",
Name = "Renamed Display",
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
});
_fixture.ClientWrapper
.GetTorrentFiles("hash1")
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "show/file1.mkv" } },
{ "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1, Path = "show/file2.mkv" } }
}
});
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
claimed.ShouldContain("/downloads/show");
claimed.Count(p => p == "/downloads/show").ShouldBe(1);
claimed.ShouldNotContain("/downloads/Renamed Display");
}
}
}
@@ -1343,4 +1343,71 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
.AddTorrentTagAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), "unlinked");
}
}
public class GetClaimedPaths_Tests : QBitServiceDCTests
{
public GetClaimedPaths_Tests(QBitServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task UsesFileList_WhenDisplayNameDivergesFromDisk()
{
var sut = _fixture.CreateSut();
var wrapper = new QBitItemWrapper(
new TorrentInfo { Hash = "hash1", Name = "Renamed Display Name", SavePath = "/downloads" },
Array.Empty<TorrentTracker>(),
false);
_fixture.ClientWrapper
.GetTorrentContentsAsync("hash1")
.Returns(new[] { new TorrentContent { Index = 0, Name = "actual-folder/data.bin", Priority = TorrentContentPriority.Normal } });
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
claimed.ShouldContain("/downloads/actual-folder");
claimed.ShouldNotContain("/downloads/Renamed Display Name");
}
[Fact]
public async Task FallsBackToSavePathAndName_WhenFileListUnavailable()
{
// no files returned (e.g. metadata not yet fetched) — claim save path + name.
var sut = _fixture.CreateSut();
var wrapper = new QBitItemWrapper(
new TorrentInfo { Hash = "hash1", Name = "some-show", SavePath = "/downloads" },
Array.Empty<TorrentTracker>(),
false);
_fixture.ClientWrapper
.GetTorrentContentsAsync("hash1")
.Returns(Array.Empty<TorrentContent>());
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
claimed.ShouldContain("/downloads/some-show");
}
[Fact]
public async Task MultiFileSharingFolder_ClaimsSingleRoot()
{
// both files live under one folder → one claimed entry, not the deep file paths.
var sut = _fixture.CreateSut();
var wrapper = new QBitItemWrapper(
new TorrentInfo { Hash = "hash1", Name = "show", SavePath = "/downloads" },
Array.Empty<TorrentTracker>(),
false);
_fixture.ClientWrapper
.GetTorrentContentsAsync("hash1")
.Returns(new[]
{
new TorrentContent { Index = 0, Name = "show/file1.mkv", Priority = TorrentContentPriority.Normal },
new TorrentContent { Index = 1, Name = "show/file2.mkv", Priority = TorrentContentPriority.Normal }
});
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
claimed.ShouldContain("/downloads/show");
claimed.Count(p => p == "/downloads/show").ShouldBe(1);
claimed.ShouldNotContain("/downloads/show/file1.mkv");
}
}
}
@@ -772,4 +772,32 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
wrapper.Category.ShouldBe("unlinked");
}
}
public class GetClaimedPaths_Tests : RTorrentServiceDCTests
{
public GetClaimedPaths_Tests(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task ClaimsBasePathAndDirectory()
{
// rTorrent resolves base_path (content root) and directory (its parent) itself;
// no file lookup, and the display name is never involved.
var sut = _fixture.CreateSut();
var wrapper = new RTorrentItemWrapper(new RTorrentTorrent
{
Hash = "HASH1",
Name = "Renamed Display",
BasePath = "/downloads/show",
Directory = "/downloads"
});
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
claimed.ShouldContain("/downloads/show");
claimed.ShouldContain("/downloads");
claimed.ShouldNotContain("/downloads/Renamed Display");
}
}
}
@@ -1001,4 +1001,36 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
.TorrentSetLocationAsync(Arg.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true);
}
}
public class GetClaimedPaths_Tests : TransmissionServiceDCTests
{
public GetClaimedPaths_Tests(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task DerivesRootFromFileList_SharedFolderDedupes()
{
// Transmission carries the files in the list response; the root is derived from them,
// not the display name.
var sut = _fixture.CreateSut();
var wrapper = new TransmissionItemWrapper(new TorrentInfo
{
HashString = "hash1",
Name = "Renamed Display",
DownloadDir = "/downloads",
Files = new[]
{
new TransmissionTorrentFiles { Name = "show/file1.mkv" },
new TransmissionTorrentFiles { Name = "show/file2.mkv" }
}
});
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
claimed.ShouldContain("/downloads/show");
claimed.Count(p => p == "/downloads/show").ShouldBe(1);
claimed.ShouldNotContain("/downloads/Renamed Display");
}
}
}
@@ -708,4 +708,33 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
await _fixture.ClientWrapper.Received(1).SetTorrentLabelAsync("hash1", "unlinked");
}
}
public class GetClaimedPaths_Tests : UTorrentServiceDCTests
{
public GetClaimedPaths_Tests(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task DerivesRootFromFetchedFiles_SharedFolderDedupes()
{
var sut = _fixture.CreateSut();
var wrapper = new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Renamed Display", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
_fixture.ClientWrapper
.GetTorrentFilesAsync("hash1")
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "show/file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 1000 },
new UTorrentFile { Name = "show/file2.mkv", Priority = 1, Index = 1, Size = 1000, Downloaded = 1000 }
});
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
claimed.ShouldContain("/downloads/show");
claimed.Count(p => p == "/downloads/show").ShouldBe(1);
claimed.ShouldNotContain("/downloads/Renamed Display");
}
}
}
@@ -82,10 +82,33 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable
svc.LoginAsync().Returns(Task.CompletedTask);
svc.GetSeedingDownloads().Returns([]);
svc.GetAllTorrentsLite().Returns(torrents);
svc.GetClaimedPathsAsync(Arg.Any<IReadOnlyList<ITorrentItemWrapper>>())
.Returns(ci => Task.FromResult(BuildDefaultClaimedPaths(ci.Arg<IReadOnlyList<ITorrentItemWrapper>>())));
_fixture.DownloadServiceFactory.GetDownloadService(clientConfig).Returns(svc);
return svc;
}
private static IReadOnlyList<string> BuildDefaultClaimedPaths(IReadOnlyList<ITorrentItemWrapper> torrents)
{
HashSet<string> paths = new(StringComparer.OrdinalIgnoreCase);
foreach (ITorrentItemWrapper torrent in torrents)
{
if (string.IsNullOrEmpty(torrent.SavePath))
{
continue;
}
paths.Add(torrent.SavePath.TrimEnd(Path.DirectorySeparatorChar));
if (!string.IsNullOrEmpty(torrent.Name))
{
paths.Add(Path.Combine(torrent.SavePath, torrent.Name).TrimEnd(Path.DirectorySeparatorChar));
}
}
return paths.ToList();
}
[Fact]
public async Task OrphanedFiles_NoEnabledClientConfigs_SkipsScan()
{
@@ -1,4 +1,3 @@
using System.Text.Json;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
@@ -240,16 +239,12 @@ public class DownloadCleanerIntegrationTests : IDisposable
cleanedEvent.SearchStatus.ShouldBeNull();
cleanedEvent.CompletedAt.ShouldBeNull();
cleanedEvent.CycleId.ShouldBeNull();
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");
}
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);
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1)
@@ -323,15 +318,11 @@ public class DownloadCleanerIntegrationTests : IDisposable
categoryEvent.SearchStatus.ShouldBeNull();
categoryEvent.CompletedAt.ShouldBeNull();
categoryEvent.CycleId.ShouldBeNull();
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);
}
categoryEvent.ItemTitle.ShouldBe("NoLinks.Movie.2024");
categoryEvent.ItemHash.ShouldBe("unlinked_hash_xyz");
categoryEvent.OldCategory.ShouldBe("completed");
categoryEvent.NewCategory.ShouldBe("unlinked");
categoryEvent.IsCategoryTag.ShouldBe(false);
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1)
@@ -1,4 +1,3 @@
using System.Text.Json;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -118,12 +117,8 @@ public class MalwareBlockerIntegrationTests : IDisposable
markedEvent.SearchStatus.ShouldBeNull();
markedEvent.CompletedAt.ShouldBeNull();
markedEvent.CycleId.ShouldBeNull();
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");
}
markedEvent.ItemTitle.ShouldBe("Suspicious.Movie.2024.1080p");
markedEvent.ItemHash.ShouldBe("MALWARE_HASH_789");
// QueueItemDeleted event
var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted);
@@ -138,14 +133,10 @@ public class MalwareBlockerIntegrationTests : IDisposable
deletedEvent.SearchStatus.ShouldBeNull();
deletedEvent.CompletedAt.ShouldBeNull();
deletedEvent.CycleId.ShouldBeNull();
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");
}
deletedEvent.ItemTitle.ShouldBe("Suspicious.Movie.2024.1080p");
deletedEvent.ItemHash.ShouldBe("MALWARE_HASH_789");
deletedEvent.RemoveFromClient.ShouldBe(true);
deletedEvent.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1)
@@ -238,14 +229,10 @@ public class MalwareBlockerIntegrationTests : IDisposable
deletedEvent.IsDryRun.ShouldBe(false);
deletedEvent.StrikeId.ShouldBeNull();
deletedEvent.SearchStatus.ShouldBeNull();
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");
}
deletedEvent.ItemTitle.ShouldBe("Suspicious.Movie.2024.1080p");
deletedEvent.ItemHash.ShouldBe("MALWARE_HASH_789");
deletedEvent.RemoveFromClient.ShouldBe(false);
deletedEvent.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.NotificationPublisher.Received(1)
.NotifyQueueItemDeleted(false, DeleteReason.AllFilesBlocked);
@@ -1,4 +1,3 @@
using System.Text.Json;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
@@ -111,12 +110,8 @@ public class QueueCleanerIntegrationTests : IDisposable
markedEvent.SearchStatus.ShouldBeNull();
markedEvent.CompletedAt.ShouldBeNull();
markedEvent.CycleId.ShouldBeNull();
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");
}
markedEvent.ItemTitle.ShouldBe("Test.Movie.2024.1080p");
markedEvent.ItemHash.ShouldBe("ABC123DEF456");
// QueueItemDeleted event
var deletedEvent = events.First(e => e.EventType == EventType.QueueItemDeleted);
@@ -131,14 +126,10 @@ public class QueueCleanerIntegrationTests : IDisposable
deletedEvent.SearchStatus.ShouldBeNull();
deletedEvent.CompletedAt.ShouldBeNull();
deletedEvent.CycleId.ShouldBeNull();
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");
}
deletedEvent.ItemTitle.ShouldBe("Test.Movie.2024.1080p");
deletedEvent.ItemHash.ShouldBe("ABC123DEF456");
deletedEvent.RemoveFromClient.ShouldBe(true);
deletedEvent.DeleteReason.ShouldBe(DeleteReason.Stalled);
// Assert Phase 4: Notification was triggered
await _fixture.NotificationPublisher.Received(1).NotifyQueueItemDeleted(true, DeleteReason.Stalled);
@@ -202,14 +193,10 @@ public class QueueCleanerIntegrationTests : IDisposable
deletedEvent.IsDryRun.ShouldBe(false);
deletedEvent.StrikeId.ShouldBeNull();
deletedEvent.SearchStatus.ShouldBeNull();
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");
}
deletedEvent.ItemTitle.ShouldBe("Test.Movie.2024.1080p");
deletedEvent.ItemHash.ShouldBe("ABC123DEF456");
deletedEvent.RemoveFromClient.ShouldBe(true);
deletedEvent.DeleteReason.ShouldBe(DeleteReason.FailedImport);
// Notification with FailedImport reason
await _fixture.NotificationPublisher.Received(1).NotifyQueueItemDeleted(true, DeleteReason.FailedImport);
@@ -303,14 +290,10 @@ public class QueueCleanerIntegrationTests : IDisposable
deletedEvent.JobRunId.ShouldBe(_fixture.JobRunId);
deletedEvent.ArrInstanceId.ShouldBe(instance.Id);
deletedEvent.IsDryRun.ShouldBe(false);
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");
}
deletedEvent.ItemTitle.ShouldBe("Test.Movie.2024.1080p");
deletedEvent.ItemHash.ShouldBe("ABC123DEF456");
deletedEvent.RemoveFromClient.ShouldBe(false);
deletedEvent.DeleteReason.ShouldBe(DeleteReason.Stalled);
await _fixture.NotificationPublisher.Received(1).NotifyQueueItemDeleted(false, DeleteReason.Stalled);
}
@@ -89,16 +89,12 @@ public class SeekerIntegrationTests : IDisposable
searchEvent.CycleId.ShouldBeNull();
searchEvent.StrikeId.ShouldBeNull();
searchEvent.TrackingId.ShouldBeNull();
searchEvent.Data.ShouldBeNull();
// 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: 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: Notification was sent
await _fixture.NotificationPublisher.Received(1).NotifySearchTriggered(
@@ -149,9 +145,6 @@ 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>());
@@ -200,15 +193,12 @@ public class SeekerIntegrationTests : IDisposable
searchEvent.CompletedAt.ShouldBeNull();
searchEvent.CycleId.ShouldBeNull();
searchEvent.StrikeId.ShouldBeNull();
searchEvent.Data.ShouldBeNull();
// 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: 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: Item remains in queue (dry run doesn't dequeue)
var remainingItems = await _fixture.DataContext.SearchQueue.CountAsync();
@@ -1,4 +1,3 @@
using System.Text.Json;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
@@ -82,19 +81,39 @@ public class StrikerIntegrationTests : IDisposable
strikeEvent.SearchStatus.ShouldBeNull();
strikeEvent.CompletedAt.ShouldBeNull();
strikeEvent.CycleId.ShouldBeNull();
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");
}
strikeEvent.ItemHash.ShouldBe("STALLED_HASH_123");
strikeEvent.ItemTitle.ShouldBe("Stalled.Movie.2024.1080p");
strikeEvent.StrikeCount.ShouldBe(1);
// 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()
{
@@ -140,14 +159,9 @@ public class StrikerIntegrationTests : IDisposable
strikeEvent.SearchStatus.ShouldBeNull();
strikeEvent.CompletedAt.ShouldBeNull();
strikeEvent.CycleId.ShouldBeNull();
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");
}
strikeEvent.ItemHash.ShouldBe("METADATA_HASH_456");
strikeEvent.ItemTitle.ShouldBe("Metadata.Movie.2024.1080p");
strikeEvent.StrikeCount.ShouldBe(1);
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.DownloadingMetadata, 1);
@@ -216,23 +230,15 @@ public class StrikerIntegrationTests : IDisposable
strikeEvent.SearchStatus.ShouldBeNull();
strikeEvent.CompletedAt.ShouldBeNull();
strikeEvent.CycleId.ShouldBeNull();
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");
strikeEvent.ItemHash.ShouldBe("FAILED_HASH_789");
strikeEvent.ItemTitle.ShouldBe("FailedImport.Movie.2024.1080p");
strikeEvent.StrikeCount.ShouldBe(1);
// 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");
}
// 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");
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.FailedImport, 1);
@@ -283,14 +289,9 @@ public class StrikerIntegrationTests : IDisposable
strikeEvent.SearchStatus.ShouldBeNull();
strikeEvent.CompletedAt.ShouldBeNull();
strikeEvent.CycleId.ShouldBeNull();
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");
}
strikeEvent.ItemHash.ShouldBe("SLOW_SPEED_HASH_111");
strikeEvent.ItemTitle.ShouldBe("SlowSpeed.Movie.2024.1080p");
strikeEvent.StrikeCount.ShouldBe(1);
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.SlowSpeed, 1);
@@ -341,14 +342,9 @@ public class StrikerIntegrationTests : IDisposable
strikeEvent.SearchStatus.ShouldBeNull();
strikeEvent.CompletedAt.ShouldBeNull();
strikeEvent.CycleId.ShouldBeNull();
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");
}
strikeEvent.ItemHash.ShouldBe("SLOW_TIME_HASH_222");
strikeEvent.ItemTitle.ShouldBe("SlowTime.Movie.2024.1080p");
strikeEvent.StrikeCount.ShouldBe(1);
// Assert: Notification sent
await _fixture.NotificationPublisher.Received(1).NotifyStrike(StrikeType.SlowTime, 1);
@@ -386,8 +382,7 @@ public class StrikerIntegrationTests : IDisposable
for (int i = 0; i < 3; i++)
{
events[i].EventType.ShouldBe(EventType.StalledStrike);
using var data = JsonDocument.Parse(events[i].Data!);
data.RootElement.GetProperty("strikeCount").GetInt32().ShouldBe(i + 1);
events[i].StrikeCount.ShouldBe(i + 1);
}
// Assert: 3 notifications with incrementing counts
@@ -449,13 +444,9 @@ 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].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);
}
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);
}
[Fact]
@@ -493,11 +484,6 @@ public class StrikerIntegrationTests : IDisposable
var events = await _fixture.EventsContext.Events.ToListAsync();
events.Count.ShouldBe(1);
events[0].EventType.ShouldBe(EventType.FailedImportStrike);
events[0].Data.ShouldNotBeNull();
using (var data = JsonDocument.Parse(events[0].Data!))
{
var reasons = data.RootElement.GetProperty("failedImportReasons");
reasons.GetArrayLength().ShouldBe(0);
}
events[0].FailedImportReasons.ShouldBeEmpty();
}
}
@@ -21,6 +21,7 @@ public static class TestEventsContextFactory
var options = new DbContextOptionsBuilder<EventsContext>()
.UseSqlite(connection)
.UseSnakeCaseNamingConvention()
.Options;
var context = new EventsContext(options);
@@ -0,0 +1,229 @@
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,4 +1,6 @@
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;
@@ -62,16 +64,22 @@ public class EventCleanupService : BackgroundService
var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
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();
GeneralConfig config = await dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
await CleanupStrikesAsync(eventsContext, dataContext);
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);
}
catch (Exception ex)
{
@@ -79,13 +87,49 @@ public class EventCleanupService : BackgroundService
}
}
private async Task CleanupStrikesAsync(EventsContext eventsContext, DataContext dataContext)
internal async Task DeleteResolvedManualEventsAsync(EventsContext eventsContext, DateTimeOffset cutoff)
{
var config = await dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync();
int deleted = await eventsContext.ManualEvents
.Where(e => e.IsResolved)
.Where(e => (e.ResolvedAt ?? e.Timestamp) < cutoff)
.ExecuteDeleteAsync();
var inactivityWindowHours = config.StrikeInactivityWindowHours;
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 cutoffDate = DateTimeOffset.UtcNow.AddHours(-inactivityWindowHours);
// Sliding window: find items whose most recent strike is older than the inactivity window.
@@ -1,5 +1,3 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Events.Interfaces;
@@ -11,8 +9,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;
@@ -29,7 +27,7 @@ public class EventPublisher : IEventPublisher
private readonly IDryRunInterceptor _dryRunInterceptor;
public EventPublisher(
EventsContext context,
EventsContext context,
IHubContext<AppHub> appHubContext,
ILogger<EventPublisher> logger,
INotificationPublisher notificationPublisher,
@@ -43,19 +41,16 @@ public class EventPublisher : IEventPublisher
}
/// <summary>
/// Generic method for publishing events to database and SignalR clients
/// 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.
/// </summary>
public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, object? data = null, Guid? trackingId = null, Guid? strikeId = null, bool? isDryRun = null)
public async Task PublishAsync(EventType eventType, string message, EventSeverity severity, Action<AppEvent>? configure = 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(),
@@ -67,6 +62,8 @@ 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);
@@ -77,16 +74,19 @@ public class EventPublisher : IEventPublisher
_logger.LogTrace("Published event: {eventType}", eventType);
}
public async Task PublishManualAsync(string message, EventSeverity severity, object? data = null, bool? isDryRun = null)
/// <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)
{
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,10 +94,45 @@ 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();
_context.ManualEvents.Add(eventEntity);
await _context.SaveChangesAsync();
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;
}
await NotifyClientsAsync(eventEntity);
@@ -121,29 +156,17 @@ public class EventPublisher : IEventPublisher
_ => throw new ArgumentOutOfRangeException(nameof(strikeType), strikeType, null)
};
dynamic data;
List<string> failedImportReasons = [];
if (strikeType is StrikeType.FailedImport)
{
QueueRecord record = ContextProvider.Get<QueueRecord>(nameof(QueueRecord));
data = new
{
hash,
itemName,
strikeCount,
strikeType,
failedImportReasons = record.StatusMessages ?? [],
};
}
else
{
data = new
{
hash,
itemName,
strikeCount,
strikeType,
};
failedImportReasons = record.StatusMessages?
.Select(m => m.Messages is { Count: > 0 }
? $"{m.Title}: {string.Join("; ", m.Messages)}"
: m.Title)
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList() ?? [];
}
bool isDryRun = await _dryRunInterceptor.IsDryRunEnabled();
@@ -153,7 +176,13 @@ public class EventPublisher : IEventPublisher
eventType,
$"Item '{itemName}' has been struck {strikeCount} times for reason '{strikeType}'",
EventSeverity.Important,
data: data,
configure: e =>
{
e.ItemTitle = itemName;
e.ItemHash = hash;
e.StrikeCount = strikeCount;
e.FailedImportReasons = failedImportReasons;
},
strikeId: strikeId,
isDryRun: isDryRun);
@@ -164,6 +193,23 @@ 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>
@@ -178,7 +224,13 @@ public class EventPublisher : IEventPublisher
EventType.QueueItemDeleted,
$"Deleting item from queue with reason: {deleteReason}",
EventSeverity.Important,
data: new { itemName, hash, removeFromClient, deleteReason });
configure: e =>
{
e.ItemTitle = itemName;
e.ItemHash = hash;
e.DeleteReason = deleteReason;
e.RemoveFromClient = removeFromClient;
});
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyQueueItemDeleted(removeFromClient, deleteReason);
@@ -198,7 +250,15 @@ public class EventPublisher : IEventPublisher
EventType.DownloadCleaned,
$"Cleaned item from download client with reason: {reason}",
EventSeverity.Important,
data: new { itemName, hash, categoryName, ratio, seedingTime = seedingTime.TotalHours, reason });
configure: e =>
{
e.ItemTitle = itemName;
e.ItemHash = hash;
e.CleanedCategory = categoryName;
e.SeedRatio = ratio;
e.SeedingTimeHours = seedingTime.TotalHours;
e.CleanReason = reason;
});
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyDownloadCleaned(ratio, seedingTime, categoryName, reason);
@@ -218,7 +278,14 @@ public class EventPublisher : IEventPublisher
EventType.CategoryChanged,
isTag ? $"Tag '{newCategory}' added to download" : $"Category changed from '{oldCategory}' to '{newCategory}'",
EventSeverity.Information,
data: new { itemName, hash, oldCategory, newCategory, isTag });
configure: e =>
{
e.ItemTitle = itemName;
e.ItemHash = hash;
e.OldCategory = oldCategory;
e.NewCategory = newCategory;
e.IsCategoryTag = isTag;
});
// Send notification (uses ContextProvider internally)
await _notificationPublisher.NotifyCategoryChanged(oldCategory, newCategory, isTag);
@@ -230,9 +297,15 @@ 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,
data: new { itemName, hash, strikeCount }
configure: e =>
{
e.ItemTitle = itemName;
e.ItemHash = hash;
e.StrikeCount = strikeCount;
}
);
}
@@ -256,31 +329,15 @@ 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();
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;
}
_context.Events.Add(eventEntity);
await _context.SaveChangesAsync();
await NotifyClientsAsync(eventEntity);
await _notificationPublisher.NotifySearchTriggered(itemTitle, searchType, searchReason);
@@ -294,7 +351,6 @@ 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)
@@ -306,17 +362,17 @@ public class EventPublisher : IEventPublisher
existingEvent.SearchStatus = status;
existingEvent.CompletedAt = DateTimeOffset.UtcNow;
if (grabbedItems is { Count: > 0 } && existingEvent.SearchEventData is not null)
if (grabbedItems is { Count: > 0 })
{
existingEvent.SearchEventData.GrabbedItems = grabbedItems;
existingEvent.GrabbedItems = grabbedItems;
}
await _context.SaveChangesAsync();
await NotifyClientsAsync(existingEvent);
if (status is SearchCommandStatus.Completed && grabbedItems is { Count: > 0 } && existingEvent.SearchEventData is not null)
if (status is SearchCommandStatus.Completed && grabbedItems is { Count: > 0 })
{
await _notificationPublisher.NotifySearchItemGrabbed(existingEvent.SearchEventData.ItemTitle, grabbedItems, instanceType, instanceUrl);
await _notificationPublisher.NotifySearchItemGrabbed(existingEvent.ItemTitle ?? string.Empty, grabbedItems, instanceType, instanceUrl);
}
}
@@ -326,9 +382,14 @@ 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,
data: new { itemName, hash }
configure: e =>
{
e.ItemTitle = itemName;
e.ItemHash = hash;
}
);
}
@@ -344,7 +405,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
@@ -378,4 +439,4 @@ public class EventPublisher : IEventPublisher
_logger.LogError(ex, "Failed to send strike to SignalR clients");
}
}
}
}
@@ -1,15 +1,18 @@
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, object? data = null, Guid? trackingId = null, Guid? strikeId = null, bool? isDryRun = null);
Task PublishAsync(EventType eventType, string message, EventSeverity severity, Action<AppEvent>? configure = null, Guid? trackingId = null, Guid? strikeId = null, bool? isDryRun = null);
Task PublishManualAsync(string message, EventSeverity severity, object? data = null, bool? isDryRun = null);
Task PublishManualAsync(ManualEventType type, string message, EventSeverity severity, Action<ManualEvent>? configure = 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);
@@ -22,7 +22,22 @@ public sealed record PlexAccountInfo
public interface IPlexAuthService
{
Task<PlexPinResult> RequestPin();
/// <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<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()
public async Task<PlexPinResult> RequestPin(string? forwardUrl = null)
{
var request = new HttpRequestMessage(HttpMethod.Post, $"{PlexApiBaseUrl}/pins");
AddPlexHeaders(request);
@@ -43,6 +43,11 @@ public sealed class PlexAuthService : IPlexAuthService
var authUrl = $"https://app.plex.tv/auth#?clientID={Uri.EscapeDataString(_clientIdentifier)}&code={Uri.EscapeDataString(pin.Code)}&context%5Bdevice%5D%5Bproduct%5D={Uri.EscapeDataString(PlexProduct)}";
if (!string.IsNullOrEmpty(forwardUrl))
{
authUrl += $"&forwardUrl={Uri.EscapeDataString(forwardUrl)}";
}
return new PlexPinResult
{
PinId = pin.Id,
@@ -5,7 +5,6 @@ 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;
@@ -155,33 +154,9 @@ public sealed class OrphanedFilesCleanupService : IOrphanedFilesCleanupService
return false;
}
foreach (ITorrentItemWrapper torrent in torrents)
foreach (string claimedPath in await downloadService.GetClaimedPathsAsync(torrents))
{
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));
claimedPaths.Add(claimedPath);
}
_logger.LogDebug("Loaded {count} torrents | {name}", torrents.Count, downloadClient.Name);
@@ -41,6 +41,27 @@ 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,6 +11,7 @@ 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;
@@ -77,6 +78,79 @@ 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,6 +37,12 @@ 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>
@@ -48,6 +48,19 @@ 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
@@ -32,6 +32,32 @@ 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))))
@@ -31,6 +31,19 @@ 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)
{
@@ -36,6 +36,19 @@ 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,12 +106,18 @@ public sealed class Striker : IStriker
.Where(s => s.DownloadItemId == downloadItem.Id && s.Type == strikeType)
.ToListAsync();
if (strikesToDelete.Count > 0)
if (strikesToDelete.Count is 0)
{
_context.Strikes.RemoveRange(strikesToDelete);
await _context.SaveChangesAsync();
_logger.LogTrace("Progress detected | resetting {reason} strikes from {strikeCount} to 0 | {name}", strikeType, strikesToDelete.Count, itemName);
return;
}
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)
@@ -186,7 +186,11 @@ 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,
data: new { itemName = record.Title, hash = record.DownloadId });
configure: e =>
{
e.ItemTitle = record.Title;
e.ItemHash = record.DownloadId;
});
}
protected SearchItem GetRecordSearchItem(InstanceType type, float version, QueueRecord record, bool isPack = false)
@@ -0,0 +1,37 @@
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; }
}
@@ -0,0 +1,20 @@
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();
}
@@ -0,0 +1,42 @@
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; }
}
@@ -0,0 +1,17 @@
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; }
}
@@ -0,0 +1,22 @@
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();
}
@@ -0,0 +1,18 @@
namespace Cleanuparr.Infrastructure.Stats;
/// <summary>
/// Current health of configured integrations. This is a cached gauge refreshed by a background service
/// (roughly every 5 minutes), not a timeframe-scoped metric; it ignores the requested timeframe.
/// </summary>
public class HealthStats
{
/// <summary>
/// Health of each enabled download client.
/// </summary>
public List<DownloadClientHealthDto> DownloadClients { get; set; } = [];
/// <summary>
/// Health of each enabled arr instance (Sonarr, Radarr, Lidarr, Readarr, Whisparr).
/// </summary>
public List<ArrInstanceHealthDto> ArrInstances { get; set; } = [];
}
@@ -1,3 +1,5 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Stats;
/// <summary>
@@ -6,11 +8,28 @@ namespace Cleanuparr.Infrastructure.Stats;
public interface IStatsService
{
/// <summary>
/// Gets aggregated statistics for the given timeframe
/// Gets aggregated statistics for the given timeframe (v1, deprecated; prefer <see cref="GetStatsV2Async"/>)
/// </summary>
/// <param name="hours">Timeframe in hours (default 24)</param>
/// <param name="includeEvents">Number of recent events to include (0 = none)</param>
/// <param name="includeStrikes">Number of recent strikes to include (0 = none)</param>
/// <returns>Aggregated stats response</returns>
Task<StatsResponse> GetStatsAsync(int hours = 24, int includeEvents = 0, int includeStrikes = 0);
/// <summary>
/// Gets 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</param>
/// <param name="includeDryRun">When true, dry-run events are included in the timeframe-scoped sections</param>
Task<StatsV2Response> GetStatsV2Async(int hours, bool includeDryRun = false);
/// <summary>
/// Gets a bucketed timeline for a single metric over the given timeframe.
/// </summary>
/// <param name="metric">strikesIssued | recovered | removed | malwareBlocked | events</param>
/// <param name="hours">Timeframe in hours</param>
/// <param name="bucket">Bucket size; when null, defaults to hourly for timeframes up to 24h, daily otherwise</param>
/// <param name="includeDryRun">When true, dry-run events are included</param>
Task<List<TimelineBucketDto>> GetTimelineAsync(string metric, int hours, TimelineBucketSize? bucket = null, bool includeDryRun = false);
}
@@ -0,0 +1,8 @@
namespace Cleanuparr.Infrastructure.Stats;
public class JobStats
{
public Dictionary<string, JobTypeStats> ByType { get; set; } = new();
public int TimeframeHours { get; set; }
}
@@ -0,0 +1,32 @@
namespace Cleanuparr.Infrastructure.Stats;
/// <summary>
/// Run stats for a single job type within the timeframe, enriched with the next scheduled run.
/// </summary>
public class JobTypeStats
{
/// <summary>
/// Total runs of this job type in the timeframe.
/// </summary>
public int TotalRuns { get; set; }
/// <summary>
/// Runs of this job type that completed successfully.
/// </summary>
public int Completed { get; set; }
/// <summary>
/// Runs of this job type that failed.
/// </summary>
public int Failed { get; set; }
/// <summary>
/// When this job type last ran, or null if it never ran in the timeframe.
/// </summary>
public DateTimeOffset? LastRunAt { get; set; }
/// <summary>
/// When this job type is next scheduled to run, or null if it is not scheduled.
/// </summary>
public DateTimeOffset? NextRunAt { get; set; }
}
@@ -0,0 +1,32 @@
namespace Cleanuparr.Infrastructure.Stats;
/// <summary>
/// Run stats for a single job type within the timeframe, enriched with the next scheduled run.
/// </summary>
public class JobTypeV2Stats
{
/// <summary>
/// Total runs of this job type in the timeframe.
/// </summary>
public int Total { get; set; }
/// <summary>
/// Runs of this job type that completed successfully.
/// </summary>
public int Completed { get; set; }
/// <summary>
/// Runs of this job type that failed.
/// </summary>
public int Failed { get; set; }
/// <summary>
/// When this job type last ran, or null if it never ran in the timeframe.
/// </summary>
public DateTimeOffset? LastRunAt { get; set; }
/// <summary>
/// When this job type is next scheduled to run, or null if it is not scheduled.
/// </summary>
public DateTimeOffset? NextRunAt { get; set; }
}
@@ -0,0 +1,27 @@
namespace Cleanuparr.Infrastructure.Stats;
/// <summary>
/// Scheduled job run outcomes for the timeframe. Job runs are recorded regardless of dry-run mode.
/// </summary>
public class JobV2Stats
{
/// <summary>
/// Total job runs in the timeframe across all job types.
/// </summary>
public int Total { get; set; }
/// <summary>
/// Job runs that completed successfully.
/// </summary>
public int Completed { get; set; }
/// <summary>
/// Job runs that failed.
/// </summary>
public int Failed { get; set; }
/// <summary>
/// Per-job-type run stats. Keys are PascalCase job-type names (QueueCleaner, MalwareBlocker, ...).
/// </summary>
public Dictionary<string, JobTypeV2Stats> ByType { get; set; } = new();
}
@@ -0,0 +1,10 @@
namespace Cleanuparr.Infrastructure.Stats;
public class RecentEventDto
{
public Guid Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public string EventType { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string Severity { get; set; } = string.Empty;
}
@@ -0,0 +1,10 @@
namespace Cleanuparr.Infrastructure.Stats;
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;
}
@@ -0,0 +1,20 @@
namespace Cleanuparr.Infrastructure.Stats;
/// <summary>
/// Downloads removed from the queue in the timeframe (QueueItemDeleted events), broken down by reason.
/// This is the single source of truth for removals: the "malware blocked" figure is simply the sum of
/// the AllFilesBlocked and AtLeastOneFileBlocked reasons. Excludes dry-run activity unless the caller opts in.
/// </summary>
public class RemovalsV2Stats
{
/// <summary>
/// Total downloads removed in the timeframe (all reasons). Equal to the sum of <see cref="ByReason"/>.
/// </summary>
public int Total { get; set; }
/// <summary>
/// Removals grouped by delete reason (Stalled, FailedImport, AllFilesBlocked, ...).
/// Keys are PascalCase delete-reason names; only reasons with activity are present.
/// </summary>
public Dictionary<string, int> ByReason { get; set; } = new();
}
@@ -0,0 +1,35 @@
namespace Cleanuparr.Infrastructure.Stats;
/// <summary>
/// Seeker search activity in the timeframe (SearchTriggered events). A single event is tracked per search and
/// updated in place as the search completes, so the counts never double-count. Excludes dry-run activity
/// unless the caller opts in.
/// </summary>
public class SearchesV2Stats
{
/// <summary>
/// Total searches triggered in the timeframe (regardless of their current status).
/// </summary>
public int Total { get; set; }
/// <summary>
/// Searches that completed successfully.
/// </summary>
public int Completed { get; set; }
/// <summary>
/// Searches that did not succeed: both failed and timed-out searches.
/// </summary>
public int Failed { get; set; }
/// <summary>
/// Total number of items grabbed as a result of the triggered searches.
/// </summary>
public int Grabbed { get; set; }
/// <summary>
/// Searches grouped by the reason they were triggered (Missing, QualityCutoffNotMet, ...).
/// Keys are PascalCase search-reason names; only reasons with activity are present.
/// </summary>
public Dictionary<string, int> ByReason { get; set; } = new();
}
@@ -1,213 +1,14 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Infrastructure.Stats;
/// <summary>
/// Aggregated application statistics for dashboard integrations
/// </summary>
public class StatsResponse
{
/// <summary>
/// Event statistics within the timeframe
/// </summary>
public EventStats Events { get; set; } = new();
/// <summary>
/// Strike statistics within the timeframe
/// </summary>
public StrikeStats Strikes { get; set; } = new();
/// <summary>
/// Job run statistics within the timeframe
/// </summary>
public JobStats Jobs { get; set; } = new();
/// <summary>
/// Current health status of download clients and arr instances
/// </summary>
public HealthStats Health { get; set; } = new();
/// <summary>
/// When this response was generated
/// </summary>
public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow;
}
/// <summary>
/// Event statistics grouped by type and severity
/// </summary>
public class EventStats
{
/// <summary>
/// Total number of events in the timeframe
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// Events grouped by EventType
/// </summary>
public Dictionary<string, int> ByType { get; set; } = new();
/// <summary>
/// Events grouped by severity level
/// </summary>
public Dictionary<string, int> BySeverity { get; set; } = new();
/// <summary>
/// The timeframe in hours that these stats cover
/// </summary>
public int TimeframeHours { get; set; }
/// <summary>
/// Recent event items (only included when includeEvents > 0)
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<RecentEventDto>? RecentItems { get; set; }
}
/// <summary>
/// Strike statistics
/// </summary>
public class StrikeStats
{
/// <summary>
/// Total number of strikes in the timeframe
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// Strikes grouped by StrikeType
/// </summary>
public Dictionary<string, int> ByType { get; set; } = new();
/// <summary>
/// Number of download items removed in the timeframe
/// </summary>
public int ItemsRemoved { get; set; }
/// <summary>
/// The timeframe in hours that these stats cover
/// </summary>
public int TimeframeHours { get; set; }
/// <summary>
/// Recent strike items (only included when includeStrikes > 0)
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<RecentStrikeDto>? RecentItems { get; set; }
}
/// <summary>
/// Job run statistics
/// </summary>
public class JobStats
{
/// <summary>
/// Job run stats grouped by JobType
/// </summary>
public Dictionary<string, JobTypeStats> ByType { get; set; } = new();
/// <summary>
/// The timeframe in hours that these stats cover
/// </summary>
public int TimeframeHours { get; set; }
}
/// <summary>
/// Statistics for a specific job type
/// </summary>
public class JobTypeStats
{
/// <summary>
/// Total number of runs in the timeframe
/// </summary>
public int TotalRuns { get; set; }
/// <summary>
/// Number of completed runs
/// </summary>
public int Completed { get; set; }
/// <summary>
/// Number of failed runs
/// </summary>
public int Failed { get; set; }
/// <summary>
/// When the last job of this type ran
/// </summary>
public DateTimeOffset? LastRunAt { get; set; }
/// <summary>
/// When this job is next scheduled to run
/// </summary>
public DateTimeOffset? NextRunAt { get; set; }
}
/// <summary>
/// Health status summary for all clients and instances
/// </summary>
public class HealthStats
{
/// <summary>
/// Health status of download clients
/// </summary>
public List<DownloadClientHealthDto> DownloadClients { get; set; } = [];
/// <summary>
/// Health status of arr instances
/// </summary>
public List<ArrInstanceHealthDto> ArrInstances { get; set; } = [];
}
/// <summary>
/// Health status DTO for a download client
/// </summary>
public class DownloadClientHealthDto
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public bool IsHealthy { get; set; }
public DateTimeOffset LastChecked { get; set; }
public double? ResponseTimeMs { get; set; }
public string? ErrorMessage { get; set; }
}
/// <summary>
/// Health status DTO for an arr instance
/// </summary>
public class ArrInstanceHealthDto
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public bool IsHealthy { get; set; }
public DateTimeOffset LastChecked { get; set; }
public string? ErrorMessage { get; set; }
}
/// <summary>
/// Recent event DTO for stats endpoint
/// </summary>
public class RecentEventDto
{
public Guid Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public string EventType { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string Severity { get; set; } = string.Empty;
public string? Data { get; set; }
}
/// <summary>
/// Recent strike DTO for stats endpoint
/// </summary>
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;
}
@@ -1,3 +1,5 @@
using System.Globalization;
using System.Text;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Services.Interfaces;
@@ -49,6 +51,295 @@ public class StatsService : IStatsService
};
}
private static readonly Dictionary<EventType, StrikeType> StrikeEventToType = new()
{
[EventType.StalledStrike] = StrikeType.Stalled,
[EventType.DownloadingMetadataStrike] = StrikeType.DownloadingMetadata,
[EventType.FailedImportStrike] = StrikeType.FailedImport,
[EventType.SlowSpeedStrike] = StrikeType.SlowSpeed,
[EventType.SlowTimeStrike] = StrikeType.SlowTime,
[EventType.DeadTorrentStrike] = StrikeType.DeadTorrent,
};
private static readonly EventType[] StrikeEventTypes = [.. StrikeEventToType.Keys];
private static readonly DeleteReason[] MalwareReasons =
[
DeleteReason.AllFilesBlocked,
DeleteReason.AtLeastOneFileBlocked,
];
/// <inheritdoc />
public async Task<StatsV2Response> GetStatsV2Async(int hours, bool includeDryRun = false)
{
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddHours(-hours);
Dictionary<string, int> byType = await MergedCountsAsync(cutoff, e => e.EventType, includeDryRun);
Dictionary<string, int> bySeverity = await MergedCountsAsync(cutoff, e => e.Severity, includeDryRun);
Dictionary<string, int> strikesByType = [];
foreach ((EventType eventType, StrikeType strikeType) in StrikeEventToType)
{
int count = byType.GetValueOrDefault(eventType.ToString(), 0);
if (count > 0)
{
strikesByType[strikeType.ToString()] = count;
}
}
return new StatsV2Response
{
Events = new EventV2Stats
{
Total = byType.Values.Sum(),
ByType = byType,
BySeverity = bySeverity,
},
Strikes = new StrikeV2Stats
{
Total = strikesByType.Values.Sum(),
ByType = strikesByType,
Recovered = byType.GetValueOrDefault(EventType.StrikeReset.ToString(), 0),
},
Removals = new RemovalsV2Stats
{
Total = byType.GetValueOrDefault(EventType.QueueItemDeleted.ToString(), 0),
ByReason = await RemovalsByReasonAsync(cutoff, includeDryRun),
},
Cleaned = new CleanedV2Stats
{
Total = byType.GetValueOrDefault(EventType.DownloadCleaned.ToString(), 0),
ByReason = await CleanedByReasonAsync(cutoff, includeDryRun),
},
Searches = await GetSearchStatsAsync(cutoff, byType, includeDryRun),
Jobs = await GetJobV2StatsAsync(cutoff),
Health = GetHealthStats(),
TimeframeHours = hours,
GeneratedAt = DateTimeOffset.UtcNow,
};
}
/// <inheritdoc />
public async Task<List<TimelineBucketDto>> GetTimelineAsync(string metric, int hours, TimelineBucketSize? bucket = null, bool includeDryRun = false)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
DateTimeOffset cutoff = now.AddHours(-hours);
TimelineBucketSize size = bucket ?? TimelineBucketing.DefaultFor(hours);
Dictionary<DateTimeOffset, int> counts = await MetricCountsAsync(cutoff, metric, size, includeDryRun);
List<TimelineBucketDto> series = [];
foreach (DateTimeOffset point in TimelineBucketing.Buckets(cutoff, now, size))
{
series.Add(new TimelineBucketDto { Date = point, Count = counts.GetValueOrDefault(point) });
}
return series;
}
private async Task<Dictionary<string, int>> MergedCountsAsync<TKey>(
DateTimeOffset cutoff,
System.Linq.Expressions.Expression<Func<Persistence.Models.Events.AppEvent, TKey>> selector,
bool includeDryRun)
where TKey : notnull
{
var grouped = await _eventsContext.Events
.Where(e => e.Timestamp >= cutoff && (includeDryRun || !e.IsDryRun))
.GroupBy(selector)
.Select(g => new { g.Key, Count = g.Count() })
.ToListAsync();
Dictionary<string, int> counts = [];
foreach (var entry in grouped)
{
string key = entry.Key.ToString() ?? string.Empty;
counts[key] = counts.GetValueOrDefault(key) + entry.Count;
}
return counts;
}
private async Task<Dictionary<string, int>> RemovalsByReasonAsync(DateTimeOffset cutoff, bool includeDryRun)
{
var grouped = await _eventsContext.Events
.Where(e => e.Timestamp >= cutoff && (includeDryRun || !e.IsDryRun)
&& e.EventType == EventType.QueueItemDeleted
&& e.DeleteReason != null && e.DeleteReason != DeleteReason.None)
.GroupBy(e => e.DeleteReason!.Value)
.Select(g => new { Reason = g.Key, Count = g.Count() })
.ToListAsync();
return grouped.ToDictionary(x => x.Reason.ToString(), x => x.Count);
}
private async Task<Dictionary<string, int>> CleanedByReasonAsync(DateTimeOffset cutoff, bool includeDryRun)
{
var grouped = await _eventsContext.Events
.Where(e => e.Timestamp >= cutoff && (includeDryRun || !e.IsDryRun)
&& e.EventType == EventType.DownloadCleaned
&& e.CleanReason != null && e.CleanReason != CleanReason.None)
.GroupBy(e => e.CleanReason!.Value)
.Select(g => new { Reason = g.Key, Count = g.Count() })
.ToListAsync();
return grouped.ToDictionary(x => x.Reason.ToString(), x => x.Count);
}
private async Task<SearchesV2Stats> GetSearchStatsAsync(DateTimeOffset cutoff, Dictionary<string, int> byType, bool includeDryRun)
{
var rows = await _eventsContext.Events
.Where(e => e.Timestamp >= cutoff && (includeDryRun || !e.IsDryRun)
&& e.EventType == EventType.SearchTriggered)
.Select(e => new { e.SearchStatus, e.SearchReason, GrabbedCount = e.GrabbedItems.Count })
.ToListAsync();
Dictionary<SearchCommandStatus, int> statusCounts = rows
.Where(r => r.SearchStatus != null)
.GroupBy(r => r.SearchStatus!.Value)
.ToDictionary(g => g.Key, g => g.Count());
Dictionary<string, int> byReason = rows
.Where(r => r.SearchReason != null)
.GroupBy(r => r.SearchReason!.Value)
.ToDictionary(g => g.Key.ToString(), g => g.Count());
return new SearchesV2Stats
{
Total = byType.GetValueOrDefault(EventType.SearchTriggered.ToString(), 0),
Completed = statusCounts.GetValueOrDefault(SearchCommandStatus.Completed, 0),
Failed = statusCounts.GetValueOrDefault(SearchCommandStatus.Failed, 0)
+ statusCounts.GetValueOrDefault(SearchCommandStatus.TimedOut, 0),
Grabbed = rows.Sum(r => r.GrabbedCount),
ByReason = byReason,
};
}
private async Task<Dictionary<DateTimeOffset, int>> MetricCountsAsync(DateTimeOffset cutoff, string metric, TimelineBucketSize size, bool includeDryRun)
{
EventType[]? types = metric switch
{
"strikesIssued" => StrikeEventTypes,
"recovered" => [EventType.StrikeReset],
"removed" => [EventType.QueueItemDeleted],
"malwareBlocked" => [EventType.QueueItemDeleted],
_ => null, // "events" or unknown → all types
};
bool malwareOnly = metric == "malwareBlocked";
List<object> parameters = [cutoff.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture)];
StringBuilder where = new("WHERE timestamp >= {0}");
if (types is not null)
{
string placeholders = string.Join(", ", types.Select((_, i) => $"{{{parameters.Count + i}}}"));
where.Append($" AND event_type IN ({placeholders})");
parameters.AddRange(types.Select(t => (object)t.ToString().ToLowerInvariant()));
}
if (malwareOnly)
{
string placeholders = string.Join(", ", MalwareReasons.Select((_, i) => $"{{{parameters.Count + i}}}"));
where.Append($" AND delete_reason IN ({placeholders})");
parameters.AddRange(MalwareReasons.Select(r => (object)r.ToString().ToLowerInvariant()));
}
if (!includeDryRun)
{
where.Append(" AND is_dry_run = 0");
}
string bucketExpr = TimelineBucketing.BucketExpr(size);
string sql = $"""
SELECT {bucketExpr} AS "bucket", COUNT(*) AS "count"
FROM events
{where}
GROUP BY {bucketExpr}
""";
List<BucketCount> rows = await _eventsContext.Database
.SqlQueryRaw<BucketCount>(sql, parameters.ToArray())
.ToListAsync();
return rows.ToDictionary(
r => TimelineBucketing.ParseKey(r.Bucket, size),
r => r.Count);
}
private sealed class BucketCount
{
public string Bucket { get; set; } = string.Empty;
public int Count { get; set; }
}
/// <summary>
/// Builds the per-job-type run stats for the timeframe, enriched with each job's next scheduled run.
/// Shared by the v1 and v2 job-stats projections.
/// </summary>
private async Task<Dictionary<string, JobTypeStats>> BuildJobTypeStatsAsync(DateTimeOffset cutoff)
{
var jobRuns = await _eventsContext.JobRuns
.Where(j => j.StartedAt >= cutoff)
.GroupBy(j => j.Type)
.Select(g => new
{
Type = g.Key,
TotalRuns = g.Count(),
Completed = g.Count(j => j.Status == JobRunStatus.Completed),
Failed = g.Count(j => j.Status == JobRunStatus.Failed),
LastRunAt = g.Max(j => j.StartedAt),
})
.ToListAsync();
Dictionary<string, JobTypeStats> byType = jobRuns.ToDictionary(
j => j.Type.ToString(),
j => new JobTypeStats
{
TotalRuns = j.TotalRuns,
Completed = j.Completed,
Failed = j.Failed,
LastRunAt = j.LastRunAt,
});
var allJobs = await _jobManagementService.GetAllJobs();
foreach (var job in allJobs)
{
if (byType.TryGetValue(job.JobType, out JobTypeStats? stats))
{
stats.NextRunAt = job.NextRunTime;
}
else
{
byType[job.JobType] = new JobTypeStats { NextRunAt = job.NextRunTime };
}
}
return byType;
}
private async Task<JobV2Stats> GetJobV2StatsAsync(DateTimeOffset cutoff)
{
Dictionary<string, JobTypeStats> byType = await BuildJobTypeStatsAsync(cutoff);
Dictionary<string, JobTypeV2Stats> byTypeV2 = byType.ToDictionary(
kvp => kvp.Key,
kvp => new JobTypeV2Stats
{
Total = kvp.Value.TotalRuns,
Completed = kvp.Value.Completed,
Failed = kvp.Value.Failed,
LastRunAt = kvp.Value.LastRunAt,
NextRunAt = kvp.Value.NextRunAt,
});
return new JobV2Stats
{
Total = byTypeV2.Values.Sum(s => s.Total),
Completed = byTypeV2.Values.Sum(s => s.Completed),
Failed = byTypeV2.Values.Sum(s => s.Failed),
ByType = byTypeV2,
};
}
private async Task<EventStats> GetEventStatsAsync(DateTimeOffset cutoff, int hours, int includeEvents)
{
var eventsByType = await _eventsContext.Events
@@ -84,7 +375,6 @@ public class StatsService : IStatsService
EventType = e.EventType.ToString(),
Message = e.Message,
Severity = e.Severity.ToString(),
Data = e.Data
})
.ToListAsync();
}
@@ -135,41 +425,7 @@ public class StatsService : IStatsService
private async Task<JobStats> GetJobStatsAsync(DateTimeOffset cutoff, int hours)
{
var jobRuns = await _eventsContext.JobRuns
.Where(j => j.StartedAt >= cutoff)
.GroupBy(j => j.Type)
.Select(g => new
{
Type = g.Key,
TotalRuns = g.Count(),
Completed = g.Count(j => j.Status == JobRunStatus.Completed),
Failed = g.Count(j => j.Status == JobRunStatus.Failed),
LastRunAt = g.Max(j => j.StartedAt)
})
.ToListAsync();
var byType = jobRuns.ToDictionary(
j => j.Type.ToString(),
j => new JobTypeStats
{
TotalRuns = j.TotalRuns,
Completed = j.Completed,
Failed = j.Failed,
LastRunAt = j.LastRunAt
});
var allJobs = await _jobManagementService.GetAllJobs();
foreach (var job in allJobs)
{
if (byType.TryGetValue(job.JobType, out var stats))
{
stats.NextRunAt = job.NextRunTime;
}
else
{
byType[job.JobType] = new JobTypeStats { NextRunAt = job.NextRunTime };
}
}
Dictionary<string, JobTypeStats> byType = await BuildJobTypeStatsAsync(cutoff);
return new JobStats
{
@@ -0,0 +1,58 @@
namespace Cleanuparr.Infrastructure.Stats;
/// <summary>
/// Aggregated application statistics for a timeframe, designed for dashboard integrations.
/// Every section except <see cref="Health"/> is scoped to the timeframe and reflects only live activity
/// unless dry-run is explicitly included. <see cref="Health"/> is a point-in-time gauge.
/// </summary>
public class StatsV2Response
{
/// <summary>
/// The raw event audit for the timeframe: total plus breakdowns by event type and severity.
/// Higher-level sections (strikes, removals, cleaned, searches) are ergonomic roll-ups derived
/// from the same events.
/// </summary>
public EventV2Stats Events { get; set; } = new();
/// <summary>
/// Strike activity in the timeframe (issued, by type, recovered).
/// </summary>
public StrikeV2Stats Strikes { get; set; } = new();
/// <summary>
/// Downloads removed in the timeframe, broken down by reason. Malware removals are the
/// AllFilesBlocked + AtLeastOneFileBlocked reasons within this breakdown.
/// </summary>
public RemovalsV2Stats Removals { get; set; } = new();
/// <summary>
/// Downloads cleaned by the download cleaner in the timeframe, broken down by reason.
/// </summary>
public CleanedV2Stats Cleaned { get; set; } = new();
/// <summary>
/// Seeker search activity in the timeframe.
/// </summary>
public SearchesV2Stats Searches { get; set; } = new();
/// <summary>
/// Scheduled job run outcomes in the timeframe, overall and per job type.
/// </summary>
public JobV2Stats Jobs { get; set; } = new();
/// <summary>
/// Current health of configured download clients and arr instances. This is a cached gauge
/// (updated by a background service roughly every 5 minutes), not a timeframe-scoped metric.
/// </summary>
public HealthStats Health { get; set; } = new();
/// <summary>
/// The timeframe the response covers, in hours, echoing the requested value after clamping.
/// </summary>
public int TimeframeHours { get; set; }
/// <summary>
/// When this response was generated (UTC).
/// </summary>
public DateTimeOffset GeneratedAt { get; set; }
}
@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace Cleanuparr.Infrastructure.Stats;
public class StrikeStats
{
public int TotalCount { get; set; }
public Dictionary<string, int> ByType { get; set; } = new();
public int ItemsRemoved { get; set; }
public int TimeframeHours { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<RecentStrikeDto>? RecentItems { get; set; }
}
@@ -0,0 +1,24 @@
namespace Cleanuparr.Infrastructure.Stats;
/// <summary>
/// Strike activity within the requested timeframe. All counts are derived from strike events
/// (StalledStrike, FailedImportStrike, etc.) and exclude dry-run activity unless the caller opts in.
/// </summary>
public class StrikeV2Stats
{
/// <summary>
/// Total strikes issued in the timeframe. Equal to the sum of <see cref="ByType"/>.
/// </summary>
public int Total { get; set; }
/// <summary>
/// Strikes issued in the timeframe, grouped by strike type (Stalled, FailedImport, SlowSpeed, ...).
/// Keys are PascalCase strike-type names; only types with activity are present.
/// </summary>
public Dictionary<string, int> ByType { get; set; } = new();
/// <summary>
/// Number of downloads that recovered in the timeframe and had their strikes reset (StrikeReset events).
/// </summary>
public int Recovered { get; set; }
}
@@ -0,0 +1,17 @@
namespace Cleanuparr.Infrastructure.Stats;
/// <summary>
/// A single point in a timeline series: the count of a metric within one bucket.
/// </summary>
public class TimelineBucketDto
{
/// <summary>
/// Start of the bucket (UTC). Granularity depends on the requested bucket size (hour, day, week, month).
/// </summary>
public DateTimeOffset Date { get; set; }
/// <summary>
/// Count of the metric within this bucket.
/// </summary>
public int Count { get; set; }
}
@@ -0,0 +1,95 @@
using System.Globalization;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Stats;
public static class TimelineBucketing
{
public static TimelineBucketSize DefaultFor(int hours) =>
hours <= 24 ? TimelineBucketSize.Hour : TimelineBucketSize.Day;
/// <summary>
/// SQLite expression that maps the <c>timestamp</c> column to a bucket key. Hour keys are
/// "yyyy-MM-dd HH"; day/week/month keys are dates ("yyyy-MM-dd"), where week is the Monday of the
/// week and month is the first of the month. Fractional seconds are trimmed via substr so SQLite's
/// date functions parse the stored "yyyy-MM-dd HH:mm:ss.fffffff" format cleanly.
/// </summary>
public static string BucketExpr(TimelineBucketSize size) => size switch
{
TimelineBucketSize.Hour => "substr(timestamp, 1, 13)",
TimelineBucketSize.Day => "substr(timestamp, 1, 10)",
TimelineBucketSize.Week => "date(substr(timestamp, 1, 19), '-' || ((strftime('%w', substr(timestamp, 1, 19)) + 6) % 7) || ' days')",
TimelineBucketSize.Month => "strftime('%Y-%m-01', substr(timestamp, 1, 19))",
_ => throw new ArgumentOutOfRangeException(nameof(size), size, null),
};
public static DateTimeOffset ParseKey(string key, TimelineBucketSize size)
{
string format = size == TimelineBucketSize.Hour ? "yyyy-MM-dd HH" : "yyyy-MM-dd";
DateTime parsed = DateTime.ParseExact(
key,
format,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
return new DateTimeOffset(parsed, TimeSpan.Zero);
}
public static IEnumerable<DateTimeOffset> Buckets(DateTimeOffset cutoff, DateTimeOffset now, TimelineBucketSize size)
{
switch (size)
{
case TimelineBucketSize.Hour:
for (DateTimeOffset hour = TruncateToHour(cutoff); hour <= TruncateToHour(now); hour = hour.AddHours(1))
{
yield return hour;
}
break;
case TimelineBucketSize.Day:
for (DateTimeOffset day = TruncateToDay(cutoff); day <= TruncateToDay(now); day = day.AddDays(1))
{
yield return day;
}
break;
case TimelineBucketSize.Week:
for (DateTimeOffset week = TruncateToWeek(cutoff); week <= TruncateToWeek(now); week = week.AddDays(7))
{
yield return week;
}
break;
case TimelineBucketSize.Month:
for (DateTimeOffset month = TruncateToMonth(cutoff); month <= TruncateToMonth(now); month = month.AddMonths(1))
{
yield return month;
}
break;
default:
throw new ArgumentOutOfRangeException(nameof(size), size, null);
}
}
private static DateTimeOffset TruncateToHour(DateTimeOffset value)
{
DateTime u = value.UtcDateTime;
return new DateTimeOffset(new DateTime(u.Year, u.Month, u.Day, u.Hour, 0, 0, DateTimeKind.Utc), TimeSpan.Zero);
}
private static DateTimeOffset TruncateToDay(DateTimeOffset value) =>
new(value.UtcDateTime.Date, TimeSpan.Zero);
private static DateTimeOffset TruncateToWeek(DateTimeOffset value)
{
DateTime date = value.UtcDateTime.Date;
int daysSinceMonday = ((int)date.DayOfWeek + 6) % 7;
return new DateTimeOffset(date.AddDays(-daysSinceMonday), TimeSpan.Zero);
}
private static DateTimeOffset TruncateToMonth(DateTimeOffset value)
{
DateTime u = value.UtcDateTime;
return new DateTimeOffset(new DateTime(u.Year, u.Month, 1, 0, 0, 0, DateTimeKind.Utc), TimeSpan.Zero);
}
}
@@ -2,7 +2,7 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Shouldly;
using Xunit;
using ValidationException = System.ComponentModel.DataAnnotations.ValidationException;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.MalwareBlocker;
@@ -23,8 +23,6 @@ public class EventsContext : DbContext
public DbSet<JobRun> JobRuns { get; set; }
public DbSet<SearchEventData> SearchEventData { get; set; }
public EventsContext()
{
}
@@ -68,14 +66,11 @@ public class EventsContext : DbContext
.WithMany()
.HasForeignKey(e => e.StrikeId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<SearchEventData>(entity =>
{
entity.HasOne(s => s.AppEvent)
.WithOne(e => e.SearchEventData)
.HasForeignKey<SearchEventData>(s => s.AppEventId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.JobRun)
.WithMany(j => j.Events)
.HasForeignKey(e => e.JobRunId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<Strike>(entity =>
@@ -84,6 +79,15 @@ public class EventsContext : DbContext
.HasConversion(new LowercaseEnumConverter<StrikeType>());
});
modelBuilder.Entity<ManualEvent>(entity =>
{
// Race-proof gate: at most one UNRESOLVED event per (Type, ItemHash).
// Partial unique index — resolved rows are exempt, so history/cooldown is unaffected.
entity.HasIndex(e => new { e.Type, e.ItemHash })
.IsUnique()
.HasFilter("\"is_resolved\" = 0");
});
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
var enumProperties = entityType.ClrType.GetProperties()
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddHistoryRetentionDays : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<ushort>(
name: "history_retention_days",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: (ushort)365);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "history_retention_days",
table: "general_configs");
}
}
}
@@ -695,6 +695,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("encryption_key");
b.Property<ushort>("HistoryRetentionDays")
.HasColumnType("INTEGER")
.HasColumnName("history_retention_days");
b.Property<string>("HttpCertificateValidation")
.IsRequired()
.HasColumnType("TEXT")
@@ -1079,7 +1083,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("created_at");
@@ -1130,7 +1135,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<DateTimeOffset>("UpdatedAt")
b.Property<string>("UpdatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("updated_at");
@@ -1577,7 +1583,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastProcessedAt")
b.Property<string>("LastProcessedAt")
.HasColumnType("TEXT")
.HasColumnName("last_processed_at");
@@ -1688,11 +1694,12 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("item_type");
b.Property<DateTimeOffset>("LastSyncedAt")
b.Property<string>("LastSyncedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("last_synced_at");
b.Property<DateTimeOffset?>("LastUpgradedAt")
b.Property<string>("LastUpgradedAt")
.HasColumnType("TEXT")
.HasColumnName("last_upgraded_at");
@@ -1747,7 +1754,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("item_type");
b.Property<DateTimeOffset>("RecordedAt")
b.Property<string>("RecordedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("recorded_at");
@@ -1783,7 +1791,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
b.Property<DateTimeOffset>("CreatedAt")
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("created_at");
@@ -1828,7 +1837,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("command_id");
b.Property<DateTimeOffset>("CreatedAt")
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("created_at");
@@ -1896,7 +1906,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("item_type");
b.Property<DateTimeOffset>("LastSearchedAt")
b.Property<string>("LastSearchedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("last_searched_at");
@@ -0,0 +1,488 @@
// <auto-generated />
using System;
using Cleanuparr.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Events
{
[DbContext(typeof(EventsContext))]
[Migration("20260706084236_EventStreamRework")]
partial class EventStreamRework
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid?>("ArrInstanceId")
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
b.Property<string>("CleanReason")
.HasColumnType("TEXT")
.HasColumnName("clean_reason");
b.Property<string>("CleanedCategory")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("cleaned_category");
b.Property<string>("CompletedAt")
.HasColumnType("TEXT")
.HasColumnName("completed_at");
b.Property<Guid?>("CycleId")
.HasColumnType("TEXT")
.HasColumnName("cycle_id");
b.Property<string>("DeleteReason")
.HasColumnType("TEXT")
.HasColumnName("delete_reason");
b.Property<Guid?>("DownloadClientId")
.HasColumnType("TEXT")
.HasColumnName("download_client_id");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("event_type");
b.PrimitiveCollection<string>("FailedImportReasons")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_reasons");
b.PrimitiveCollection<string>("GrabbedItems")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("grabbed_items");
b.Property<bool?>("IsCategoryTag")
.HasColumnType("INTEGER")
.HasColumnName("is_category_tag");
b.Property<bool>("IsDryRun")
.HasColumnType("INTEGER")
.HasColumnName("is_dry_run");
b.Property<string>("ItemHash")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("item_hash");
b.Property<string>("ItemTitle")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("item_title");
b.Property<Guid?>("JobRunId")
.HasColumnType("TEXT")
.HasColumnName("job_run_id");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT")
.HasColumnName("message");
b.Property<string>("NewCategory")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("new_category");
b.Property<string>("OldCategory")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("old_category");
b.Property<bool?>("RemoveFromClient")
.HasColumnType("INTEGER")
.HasColumnName("remove_from_client");
b.Property<string>("SearchReason")
.HasColumnType("TEXT")
.HasColumnName("search_reason");
b.Property<string>("SearchStatus")
.HasColumnType("TEXT")
.HasColumnName("search_status");
b.Property<string>("SearchType")
.HasColumnType("TEXT")
.HasColumnName("search_type");
b.Property<double?>("SeedRatio")
.HasColumnType("REAL")
.HasColumnName("seed_ratio");
b.Property<double?>("SeedingTimeHours")
.HasColumnType("REAL")
.HasColumnName("seeding_time_hours");
b.Property<string>("Severity")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("severity");
b.Property<int?>("StrikeCount")
.HasColumnType("INTEGER")
.HasColumnName("strike_count");
b.Property<Guid?>("StrikeId")
.HasColumnType("TEXT")
.HasColumnName("strike_id");
b.Property<string>("Timestamp")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("timestamp");
b.Property<Guid?>("TrackingId")
.HasColumnType("TEXT")
.HasColumnName("tracking_id");
b.HasKey("Id")
.HasName("pk_events");
b.HasIndex("ArrInstanceId")
.HasDatabaseName("ix_events_arr_instance_id");
b.HasIndex("CycleId")
.HasDatabaseName("ix_events_cycle_id");
b.HasIndex("DeleteReason")
.HasDatabaseName("ix_events_delete_reason");
b.HasIndex("EventType")
.HasDatabaseName("ix_events_event_type");
b.HasIndex("JobRunId")
.HasDatabaseName("ix_events_job_run_id");
b.HasIndex("Severity")
.HasDatabaseName("ix_events_severity");
b.HasIndex("StrikeId")
.HasDatabaseName("ix_events_strike_id");
b.HasIndex("Timestamp")
.IsDescending()
.HasDatabaseName("ix_events_timestamp");
b.ToTable("events", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("DownloadClientName")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("download_client_name");
b.Property<string>("DownloadClientType")
.HasColumnType("TEXT")
.HasColumnName("download_client_type");
b.Property<string>("InstanceType")
.HasColumnType("TEXT")
.HasColumnName("instance_type");
b.Property<string>("InstanceUrl")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("instance_url");
b.Property<bool>("IsDryRun")
.HasColumnType("INTEGER")
.HasColumnName("is_dry_run");
b.Property<bool>("IsResolved")
.HasColumnType("INTEGER")
.HasColumnName("is_resolved");
b.Property<string>("ItemHash")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("item_hash");
b.Property<string>("ItemTitle")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("item_title");
b.Property<Guid?>("JobRunId")
.HasColumnType("TEXT")
.HasColumnName("job_run_id");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("TEXT")
.HasColumnName("message");
b.Property<string>("ResolvedAt")
.HasColumnType("TEXT")
.HasColumnName("resolved_at");
b.Property<string>("Severity")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("severity");
b.Property<int?>("StrikeCount")
.HasColumnType("INTEGER")
.HasColumnName("strike_count");
b.Property<string>("Timestamp")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("timestamp");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_manual_events");
b.HasIndex("InstanceType")
.HasDatabaseName("ix_manual_events_instance_type");
b.HasIndex("IsResolved")
.HasDatabaseName("ix_manual_events_is_resolved");
b.HasIndex("JobRunId")
.HasDatabaseName("ix_manual_events_job_run_id");
b.HasIndex("Message")
.HasDatabaseName("ix_manual_events_message");
b.HasIndex("Severity")
.HasDatabaseName("ix_manual_events_severity");
b.HasIndex("Timestamp")
.IsDescending()
.HasDatabaseName("ix_manual_events_timestamp");
b.HasIndex("Type", "ItemHash")
.IsUnique()
.HasDatabaseName("ix_manual_events_type_item_hash")
.HasFilter("\"is_resolved\" = 0");
b.ToTable("manual_events", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("DownloadId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("download_id");
b.Property<bool>("IsMarkedForRemoval")
.HasColumnType("INTEGER")
.HasColumnName("is_marked_for_removal");
b.Property<bool>("IsRemoved")
.HasColumnType("INTEGER")
.HasColumnName("is_removed");
b.Property<bool>("IsReturning")
.HasColumnType("INTEGER")
.HasColumnName("is_returning");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id")
.HasName("pk_download_items");
b.HasIndex("DownloadId")
.IsUnique()
.HasDatabaseName("ix_download_items_download_id");
b.ToTable("download_items", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.JobRun", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CompletedAt")
.HasColumnType("TEXT")
.HasColumnName("completed_at");
b.Property<string>("StartedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_job_runs");
b.HasIndex("StartedAt")
.IsDescending()
.HasDatabaseName("ix_job_runs_started_at");
b.HasIndex("Type")
.HasDatabaseName("ix_job_runs_type");
b.ToTable("job_runs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<Guid>("DownloadItemId")
.HasColumnType("TEXT")
.HasColumnName("download_item_id");
b.Property<bool>("IsDryRun")
.HasColumnType("INTEGER")
.HasColumnName("is_dry_run");
b.Property<Guid>("JobRunId")
.HasColumnType("TEXT")
.HasColumnName("job_run_id");
b.Property<long?>("LastDownloadedBytes")
.HasColumnType("INTEGER")
.HasColumnName("last_downloaded_bytes");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_strikes");
b.HasIndex("CreatedAt")
.HasDatabaseName("ix_strikes_created_at");
b.HasIndex("JobRunId")
.HasDatabaseName("ix_strikes_job_run_id");
b.HasIndex("DownloadItemId", "Type")
.HasDatabaseName("ix_strikes_download_item_id_type");
b.ToTable("strikes", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun")
.WithMany("Events")
.HasForeignKey("JobRunId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_events_job_runs_job_run_id");
b.HasOne("Cleanuparr.Persistence.Models.State.Strike", "Strike")
.WithMany()
.HasForeignKey("StrikeId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_events_strikes_strike_id");
b.Navigation("JobRun");
b.Navigation("Strike");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun")
.WithMany("ManualEvents")
.HasForeignKey("JobRunId")
.HasConstraintName("fk_manual_events_job_runs_job_run_id");
b.Navigation("JobRun");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.State.DownloadItem", "DownloadItem")
.WithMany("Strikes")
.HasForeignKey("DownloadItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_strikes_download_items_download_item_id");
b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun")
.WithMany("Strikes")
.HasForeignKey("JobRunId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_strikes_job_runs_job_run_id");
b.Navigation("DownloadItem");
b.Navigation("JobRun");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b =>
{
b.Navigation("Strikes");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.JobRun", b =>
{
b.Navigation("Events");
b.Navigation("ManualEvents");
b.Navigation("Strikes");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,334 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Events
{
/// <inheritdoc />
public partial class EventStreamRework : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM events;");
migrationBuilder.Sql("DELETE FROM manual_events;");
migrationBuilder.DropForeignKey(
name: "fk_events_job_runs_job_run_id",
table: "events");
migrationBuilder.DropTable(
name: "search_event_data");
migrationBuilder.DropIndex(
name: "ix_events_download_client_id",
table: "events");
migrationBuilder.DropIndex(
name: "ix_events_message",
table: "events");
migrationBuilder.RenameColumn(
name: "data",
table: "manual_events",
newName: "resolved_at");
migrationBuilder.RenameColumn(
name: "data",
table: "events",
newName: "search_type");
migrationBuilder.AddColumn<string>(
name: "item_hash",
table: "manual_events",
type: "TEXT",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "item_title",
table: "manual_events",
type: "TEXT",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "strike_count",
table: "manual_events",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "type",
table: "manual_events",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "clean_reason",
table: "events",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "cleaned_category",
table: "events",
type: "TEXT",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "delete_reason",
table: "events",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "failed_import_reasons",
table: "events",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<string>(
name: "grabbed_items",
table: "events",
type: "TEXT",
nullable: false,
defaultValue: "[]");
migrationBuilder.AddColumn<bool>(
name: "is_category_tag",
table: "events",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "item_hash",
table: "events",
type: "TEXT",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "item_title",
table: "events",
type: "TEXT",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "new_category",
table: "events",
type: "TEXT",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "old_category",
table: "events",
type: "TEXT",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "remove_from_client",
table: "events",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "search_reason",
table: "events",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "seed_ratio",
table: "events",
type: "REAL",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "seeding_time_hours",
table: "events",
type: "REAL",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "strike_count",
table: "events",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_manual_events_type_item_hash",
table: "manual_events",
columns: new[] { "type", "item_hash" },
unique: true,
filter: "\"is_resolved\" = 0");
migrationBuilder.CreateIndex(
name: "ix_events_delete_reason",
table: "events",
column: "delete_reason");
migrationBuilder.AddForeignKey(
name: "fk_events_job_runs_job_run_id",
table: "events",
column: "job_run_id",
principalTable: "job_runs",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_events_job_runs_job_run_id",
table: "events");
migrationBuilder.DropIndex(
name: "ix_manual_events_type_item_hash",
table: "manual_events");
migrationBuilder.DropIndex(
name: "ix_events_delete_reason",
table: "events");
migrationBuilder.DropColumn(
name: "item_hash",
table: "manual_events");
migrationBuilder.DropColumn(
name: "item_title",
table: "manual_events");
migrationBuilder.DropColumn(
name: "strike_count",
table: "manual_events");
migrationBuilder.DropColumn(
name: "type",
table: "manual_events");
migrationBuilder.DropColumn(
name: "clean_reason",
table: "events");
migrationBuilder.DropColumn(
name: "cleaned_category",
table: "events");
migrationBuilder.DropColumn(
name: "delete_reason",
table: "events");
migrationBuilder.DropColumn(
name: "failed_import_reasons",
table: "events");
migrationBuilder.DropColumn(
name: "grabbed_items",
table: "events");
migrationBuilder.DropColumn(
name: "is_category_tag",
table: "events");
migrationBuilder.DropColumn(
name: "item_hash",
table: "events");
migrationBuilder.DropColumn(
name: "item_title",
table: "events");
migrationBuilder.DropColumn(
name: "new_category",
table: "events");
migrationBuilder.DropColumn(
name: "old_category",
table: "events");
migrationBuilder.DropColumn(
name: "remove_from_client",
table: "events");
migrationBuilder.DropColumn(
name: "search_reason",
table: "events");
migrationBuilder.DropColumn(
name: "seed_ratio",
table: "events");
migrationBuilder.DropColumn(
name: "seeding_time_hours",
table: "events");
migrationBuilder.DropColumn(
name: "strike_count",
table: "events");
migrationBuilder.RenameColumn(
name: "resolved_at",
table: "manual_events",
newName: "data");
migrationBuilder.RenameColumn(
name: "search_type",
table: "events",
newName: "data");
migrationBuilder.CreateTable(
name: "search_event_data",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
app_event_id = table.Column<Guid>(type: "TEXT", nullable: false),
grabbed_items = table.Column<string>(type: "TEXT", nullable: false),
item_title = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
search_reason = table.Column<string>(type: "TEXT", nullable: false),
search_type = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_search_event_data", x => x.id);
table.ForeignKey(
name: "fk_search_event_data_events_app_event_id",
column: x => x.app_event_id,
principalTable: "events",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_events_download_client_id",
table: "events",
column: "download_client_id");
migrationBuilder.CreateIndex(
name: "ix_events_message",
table: "events",
column: "message");
migrationBuilder.CreateIndex(
name: "ix_search_event_data_app_event_id",
table: "search_event_data",
column: "app_event_id",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_events_job_runs_job_run_id",
table: "events",
column: "job_run_id",
principalTable: "job_runs",
principalColumn: "id");
}
}
}
@@ -28,7 +28,16 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
b.Property<DateTimeOffset?>("CompletedAt")
b.Property<string>("CleanReason")
.HasColumnType("TEXT")
.HasColumnName("clean_reason");
b.Property<string>("CleanedCategory")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("cleaned_category");
b.Property<string>("CompletedAt")
.HasColumnType("TEXT")
.HasColumnName("completed_at");
@@ -36,9 +45,9 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("TEXT")
.HasColumnName("cycle_id");
b.Property<string>("Data")
b.Property<string>("DeleteReason")
.HasColumnType("TEXT")
.HasColumnName("data");
.HasColumnName("delete_reason");
b.Property<Guid?>("DownloadClientId")
.HasColumnType("TEXT")
@@ -49,10 +58,34 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("TEXT")
.HasColumnName("event_type");
b.PrimitiveCollection<string>("FailedImportReasons")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_reasons");
b.PrimitiveCollection<string>("GrabbedItems")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("grabbed_items");
b.Property<bool?>("IsCategoryTag")
.HasColumnType("INTEGER")
.HasColumnName("is_category_tag");
b.Property<bool>("IsDryRun")
.HasColumnType("INTEGER")
.HasColumnName("is_dry_run");
b.Property<string>("ItemHash")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("item_hash");
b.Property<string>("ItemTitle")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("item_title");
b.Property<Guid?>("JobRunId")
.HasColumnType("TEXT")
.HasColumnName("job_run_id");
@@ -63,20 +96,55 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("TEXT")
.HasColumnName("message");
b.Property<string>("NewCategory")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("new_category");
b.Property<string>("OldCategory")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("old_category");
b.Property<bool?>("RemoveFromClient")
.HasColumnType("INTEGER")
.HasColumnName("remove_from_client");
b.Property<string>("SearchReason")
.HasColumnType("TEXT")
.HasColumnName("search_reason");
b.Property<string>("SearchStatus")
.HasColumnType("TEXT")
.HasColumnName("search_status");
b.Property<string>("SearchType")
.HasColumnType("TEXT")
.HasColumnName("search_type");
b.Property<double?>("SeedRatio")
.HasColumnType("REAL")
.HasColumnName("seed_ratio");
b.Property<double?>("SeedingTimeHours")
.HasColumnType("REAL")
.HasColumnName("seeding_time_hours");
b.Property<string>("Severity")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("severity");
b.Property<int?>("StrikeCount")
.HasColumnType("INTEGER")
.HasColumnName("strike_count");
b.Property<Guid?>("StrikeId")
.HasColumnType("TEXT")
.HasColumnName("strike_id");
b.Property<DateTimeOffset>("Timestamp")
b.Property<string>("Timestamp")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("timestamp");
@@ -93,8 +161,8 @@ namespace Cleanuparr.Persistence.Migrations.Events
b.HasIndex("CycleId")
.HasDatabaseName("ix_events_cycle_id");
b.HasIndex("DownloadClientId")
.HasDatabaseName("ix_events_download_client_id");
b.HasIndex("DeleteReason")
.HasDatabaseName("ix_events_delete_reason");
b.HasIndex("EventType")
.HasDatabaseName("ix_events_event_type");
@@ -102,9 +170,6 @@ namespace Cleanuparr.Persistence.Migrations.Events
b.HasIndex("JobRunId")
.HasDatabaseName("ix_events_job_run_id");
b.HasIndex("Message")
.HasDatabaseName("ix_events_message");
b.HasIndex("Severity")
.HasDatabaseName("ix_events_severity");
@@ -125,10 +190,6 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Data")
.HasColumnType("TEXT")
.HasColumnName("data");
b.Property<string>("DownloadClientName")
.HasMaxLength(200)
.HasColumnType("TEXT")
@@ -155,6 +216,16 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("INTEGER")
.HasColumnName("is_resolved");
b.Property<string>("ItemHash")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("item_hash");
b.Property<string>("ItemTitle")
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("item_title");
b.Property<Guid?>("JobRunId")
.HasColumnType("TEXT")
.HasColumnName("job_run_id");
@@ -165,15 +236,29 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("TEXT")
.HasColumnName("message");
b.Property<string>("ResolvedAt")
.HasColumnType("TEXT")
.HasColumnName("resolved_at");
b.Property<string>("Severity")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("severity");
b.Property<DateTimeOffset>("Timestamp")
b.Property<int?>("StrikeCount")
.HasColumnType("INTEGER")
.HasColumnName("strike_count");
b.Property<string>("Timestamp")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("timestamp");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_manual_events");
@@ -196,49 +281,12 @@ namespace Cleanuparr.Persistence.Migrations.Events
.IsDescending()
.HasDatabaseName("ix_manual_events_timestamp");
b.ToTable("manual_events", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.SearchEventData", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("AppEventId")
.HasColumnType("TEXT")
.HasColumnName("app_event_id");
b.PrimitiveCollection<string>("GrabbedItems")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("grabbed_items");
b.Property<string>("ItemTitle")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("item_title");
b.Property<string>("SearchReason")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("search_reason");
b.Property<string>("SearchType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("search_type");
b.HasKey("Id")
.HasName("pk_search_event_data");
b.HasIndex("AppEventId")
b.HasIndex("Type", "ItemHash")
.IsUnique()
.HasDatabaseName("ix_search_event_data_app_event_id");
.HasDatabaseName("ix_manual_events_type_item_hash")
.HasFilter("\"is_resolved\" = 0");
b.ToTable("search_event_data", (string)null);
b.ToTable("manual_events", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b =>
@@ -289,11 +337,12 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset?>("CompletedAt")
b.Property<string>("CompletedAt")
.HasColumnType("TEXT")
.HasColumnName("completed_at");
b.Property<DateTimeOffset>("StartedAt")
b.Property<string>("StartedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("started_at");
@@ -326,7 +375,8 @@ namespace Cleanuparr.Persistence.Migrations.Events
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("created_at");
@@ -371,6 +421,7 @@ namespace Cleanuparr.Persistence.Migrations.Events
b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun")
.WithMany("Events")
.HasForeignKey("JobRunId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_events_job_runs_job_run_id");
b.HasOne("Cleanuparr.Persistence.Models.State.Strike", "Strike")
@@ -394,18 +445,6 @@ namespace Cleanuparr.Persistence.Migrations.Events
b.Navigation("JobRun");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.SearchEventData", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Events.AppEvent", "AppEvent")
.WithOne("SearchEventData")
.HasForeignKey("Cleanuparr.Persistence.Models.Events.SearchEventData", "AppEventId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_search_event_data_events_app_event_id");
b.Navigation("AppEvent");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.State.DownloadItem", "DownloadItem")
@@ -427,11 +466,6 @@ namespace Cleanuparr.Persistence.Migrations.Events
b.Navigation("JobRun");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b =>
{
b.Navigation("SearchEventData");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b =>
{
b.Navigation("Strikes");
@@ -31,6 +31,11 @@ public sealed record GeneralConfig : IConfig
public ushort StrikeInactivityWindowHours { get; set; } = 24;
/// <summary>
/// How long archived strike/event history is retained before being pruned, in days.
/// </summary>
public ushort HistoryRetentionDays { get; set; } = 365;
public LoggingConfig Log { get; set; } = new();
public AuthConfig Auth { get; set; } = new();
@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using ValidationException = System.ComponentModel.DataAnnotations.ValidationException;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
@@ -13,12 +13,10 @@ namespace Cleanuparr.Persistence.Models.Events;
[Index(nameof(Timestamp), IsDescending = [true])]
[Index(nameof(EventType))]
[Index(nameof(Severity))]
[Index(nameof(Message))]
[Index(nameof(StrikeId))]
[Index(nameof(JobRunId))]
[Index(nameof(ArrInstanceId))]
[Index(nameof(DownloadClientId))]
[Index(nameof(CycleId))]
[Index(nameof(DeleteReason))]
public class AppEvent : IEvent
{
[Key]
@@ -33,9 +31,6 @@ public class AppEvent : IEvent
[MaxLength(1000)]
public string Message { get; set; } = string.Empty;
/// <inheritdoc/>
public string? Data { get; set; }
[Required]
public required EventSeverity Severity { get; set; }
@@ -81,7 +76,92 @@ public class AppEvent : IEvent
public bool IsDryRun { get; set; }
public SearchEventData? SearchEventData { get; set; }
// Item context (most events)
/// <summary>
/// Title of the download item this event refers to
/// </summary>
[MaxLength(500)]
public string? ItemTitle { get; set; }
/// <summary>
/// Hash / download ID of the item this event refers to
/// </summary>
[MaxLength(100)]
public string? ItemHash { get; set; }
/// <summary>
/// Strike number at the time of the event (strike / reset events)
/// </summary>
public int? StrikeCount { get; set; }
/// <summary>
/// Import failure reasons (FailedImportStrike events)
/// </summary>
public List<string> FailedImportReasons { get; set; } = [];
/// <summary>
/// Reason a queue item was deleted (QueueItemDeleted events)
/// </summary>
public DeleteReason? DeleteReason { get; set; }
/// <summary>
/// Whether the item was also removed from the download client (QueueItemDeleted events)
/// </summary>
public bool? RemoveFromClient { get; set; }
/// <summary>
/// Reason a download was cleaned (DownloadCleaned events)
/// </summary>
public CleanReason? CleanReason { get; set; }
/// <summary>
/// Category of the cleaned download (DownloadCleaned events)
/// </summary>
[MaxLength(200)]
public string? CleanedCategory { get; set; }
/// <summary>
/// Seed ratio at the time of cleaning (DownloadCleaned events)
/// </summary>
public double? SeedRatio { get; set; }
/// <summary>
/// Seeding time in hours at the time of cleaning (DownloadCleaned events)
/// </summary>
public double? SeedingTimeHours { get; set; }
/// <summary>
/// Previous category (CategoryChanged events)
/// </summary>
[MaxLength(200)]
public string? OldCategory { get; set; }
/// <summary>
/// New category or tag (CategoryChanged events)
/// </summary>
[MaxLength(200)]
public string? NewCategory { get; set; }
/// <summary>
/// Whether the category change was a tag rather than a category (CategoryChanged events)
/// </summary>
public bool? IsCategoryTag { get; set; }
/// <summary>
/// Type of search (SearchTriggered events)
/// </summary>
public SeekerSearchType? SearchType { get; set; }
/// <summary>
/// Reason a search was triggered (SearchTriggered events)
/// </summary>
public SeekerSearchReason? SearchReason { get; set; }
/// <summary>
/// Titles of items grabbed after search completion, populated by SeekerCommandMonitor (SearchTriggered events)
/// </summary>
public List<string> GrabbedItems { get; set; } = [];
// Used only for notifications
@@ -3,11 +3,6 @@
public interface IEvent
{
Guid Id { get; set; }
DateTimeOffset Timestamp { get; set; }
/// <summary>
/// JSON data associated with the event
/// </summary>
string? Data { get; set; }
}
@@ -26,13 +26,40 @@ public class ManualEvent
[MaxLength(1000)]
public string Message { get; set; } = string.Empty;
public string? Data { get; set; }
[Required]
public required EventSeverity Severity { get; set; }
/// <summary>
/// Discriminator used to gate duplicate unresolved events per item.
/// </summary>
[Required]
public required ManualEventType Type { get; set; }
/// <summary>
/// Title of the download item this event refers to
/// </summary>
[MaxLength(500)]
public string? ItemTitle { get; set; }
/// <summary>
/// Hash / download ID of the item this event refers to
/// </summary>
[MaxLength(100)]
public string? ItemHash { get; set; }
/// <summary>
/// Strike number at the time of the event, when applicable
/// </summary>
public int? StrikeCount { get; set; }
public bool IsResolved { get; set; }
/// <summary>
/// When the event was resolved. Used to enforce the post-resolve cooldown before an
/// identical event can be re-created. Null while unresolved.
/// </summary>
public DateTimeOffset? ResolvedAt { get; set; }
public Guid? JobRunId { get; set; }
[JsonIgnore]
@@ -1,32 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Persistence.Models.Events;
/// <summary>
/// Stores structured data for SearchTriggered events.
/// One record per searched item.
/// </summary>
public class SearchEventData
{
[Key]
public Guid Id { get; set; } = Guid.CreateVersion7();
public Guid AppEventId { get; set; }
[JsonIgnore]
public AppEvent AppEvent { get; set; } = null!;
[MaxLength(500)]
public string ItemTitle { get; set; } = string.Empty;
public SeekerSearchType SearchType { get; set; }
public SeekerSearchReason SearchReason { get; set; }
/// <summary>
/// Titles of items grabbed after search completion, populated by SeekerCommandMonitor.
/// </summary>
public List<string> GrabbedItems { get; set; } = [];
}
+1
View File
@@ -53,6 +53,7 @@
}
],
"styles": [
"node_modules/@angular/cdk/overlay-prebuilt.css",
"src/styles.scss"
],
"stylePreprocessorOptions": {
+37
View File
@@ -0,0 +1,37 @@
// @ts-check
const eslint = require('@eslint/js');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');
const prettier = require('eslint-config-prettier');
module.exports = tseslint.config(
{
ignores: ['dist/**', '.angular/**', 'node_modules/**', 'public/**'],
},
{
files: ['**/*.ts'],
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended,
...tseslint.configs.stylistic,
...angular.configs.tsRecommended,
prettier,
],
processor: angular.processInlineTemplates,
rules: {
'@angular-eslint/directive-selector': [
'error',
{ type: 'attribute', prefix: 'app', style: 'camelCase' },
],
'@angular-eslint/component-selector': [
'error',
{ type: 'element', prefix: 'app', style: 'kebab-case' },
],
},
},
{
files: ['**/*.html'],
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
rules: {},
},
);
+3219 -1989
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff. Show more