mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-04-04 14:25:30 -04:00
Add missing and upgrade search (#507)
This commit is contained in:
@@ -23,6 +23,9 @@ Cleanuparr was created primarily to address malicious files, such as `*.lnk` or
|
||||
> - Remove and block downloads blocked by qBittorrent or by Cleanuparr's **Malware Blocker**.
|
||||
> - Remove and block known malware based on patterns found by the community.
|
||||
> - Automatically trigger a search for downloads removed from the arrs.
|
||||
> - Proactively search for **missing** items across your Radarr and Sonarr libraries.
|
||||
> - Search for **quality upgrades** for items that haven't met their quality profile's cutoff (a.k.a. **Cutoff Unmet**).
|
||||
> - Search for **custom format score upgrades** with automatic score tracking.
|
||||
> - Clean up downloads that have been **seeding** for a certain amount of time.
|
||||
> - Remove downloads that are **orphaned**/have no **hardlinks**/are not referenced by the arrs anymore (with [cross-seed](https://www.cross-seed.org/) support).
|
||||
> - Notify on strike or download removal.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Auth;
|
||||
@@ -83,14 +86,14 @@ public class LoginTimingTests : IClassFixture<TimingTestWebApplicationFactory>
|
||||
[Fact, TestPriority(4)]
|
||||
public async Task Login_LockedOutUser_StillCallsPasswordVerification()
|
||||
{
|
||||
// Trigger lockout by making several failed login attempts
|
||||
for (var i = 0; i < 5; i++)
|
||||
// Set lockout state directly in the database to avoid timing sensitivity
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
await _client.PostAsJsonAsync("/api/auth/login", new
|
||||
{
|
||||
username = "timingtest",
|
||||
password = "WrongPassword!"
|
||||
});
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
var user = await context.Users.FirstAsync();
|
||||
user.FailedLoginAttempts = 5;
|
||||
user.LockoutEnd = DateTime.UtcNow.AddMinutes(5);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
_factory.TrackingPasswordService.Reset();
|
||||
@@ -103,6 +106,16 @@ public class LoginTimingTests : IClassFixture<TimingTestWebApplicationFactory>
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
|
||||
_factory.TrackingPasswordService.VerifyPasswordCallCount.ShouldBeGreaterThanOrEqualTo(1);
|
||||
|
||||
// Reset lockout for subsequent tests
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<UsersContext>();
|
||||
var user = await context.Users.FirstAsync();
|
||||
user.FailedLoginAttempts = 0;
|
||||
user.LockoutEnd = null;
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact, TestPriority(5)]
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shouldly;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker;
|
||||
|
||||
public class CustomFormatScoreControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly CustomFormatScoreController _controller;
|
||||
|
||||
public CustomFormatScoreControllerTests()
|
||||
{
|
||||
_dataContext = SeekerTestDataFactory.CreateDataContext();
|
||||
_controller = new CustomFormatScoreController(_dataContext);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static JsonElement GetResponseBody(IActionResult result)
|
||||
{
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
#region GetCustomFormatScores Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithPageBelowMinimum_ClampsToOne()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Movie B", currentScore: 200, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(page: -5, pageSize: 50);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("Page").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithPageSizeAboveMaximum_ClampsToHundred()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie A", currentScore: 100, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 999);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("PageSize").GetInt32().ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithHideMetTrue_ExcludesItemsAtOrAboveCutoff()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below Cutoff", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "At Cutoff", currentScore: 500, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "Above Cutoff", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(hideMet: true);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Below Cutoff");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithSearchFilter_ReturnsMatchingTitlesOnly()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "The Matrix", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "Inception", currentScore: 200, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "The Matrix Reloaded", currentScore: 300, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(search: "matrix");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithSortByDate_OrdersByLastSyncedDescending()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Older", currentScore: 100, cutoffScore: 500,
|
||||
lastSynced: DateTime.UtcNow.AddHours(-2));
|
||||
AddScoreEntry(radarr.Id, 2, "Newer", currentScore: 200, cutoffScore: 500,
|
||||
lastSynced: DateTime.UtcNow.AddHours(-1));
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(sortBy: "date");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Newer");
|
||||
body.GetProperty("Items")[1].GetProperty("Title").GetString().ShouldBe("Older");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_WithInstanceIdFilter_ReturnsOnlyThatInstance()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(sonarr.Id, 2, "Series", currentScore: 200, cutoffScore: 500,
|
||||
itemType: InstanceType.Sonarr);
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(instanceId: radarr.Id);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Movie");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCustomFormatScores_ReturnsCorrectTotalPagesCalculation()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
for (int i = 1; i <= 7; i++)
|
||||
{
|
||||
AddScoreEntry(radarr.Id, i, $"Movie {i}", currentScore: 100, cutoffScore: 500);
|
||||
}
|
||||
|
||||
var result = await _controller.GetCustomFormatScores(page: 1, pageSize: 3);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(7);
|
||||
body.GetProperty("TotalPages").GetInt32().ShouldBe(3); // ceil(7/3) = 3
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetRecentUpgrades Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithNoHistory_ReturnsEmptyList()
|
||||
{
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithSingleEntryPerItem_ReturnsNoUpgrades()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithScoreIncrease_DetectsUpgrade()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
var upgrade = body.GetProperty("Items")[0];
|
||||
upgrade.GetProperty("PreviousScore").GetInt32().ShouldBe(100);
|
||||
upgrade.GetProperty("NewScore").GetInt32().ShouldBe(250);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithScoreDecrease_DoesNotCountAsUpgrade()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 150, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithMultipleUpgradesInSameGroup_CountsEach()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// 100 -> 200 -> 300 = two upgrades for the same item
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_WithDaysFilter_ExcludesOlderHistory()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// Old upgrade (outside 7-day window)
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-20));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 250, recordedAt: DateTime.UtcNow.AddDays(-15));
|
||||
// Recent upgrade (inside 7-day window)
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades(days: 7);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentUpgrades_ReturnsSortedByMostRecentFirst()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
// Item 1: upgrade happened 5 days ago
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-6));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-5));
|
||||
// Item 2: upgrade happened 1 day ago
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-2));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
var result = await _controller.GetRecentUpgrades();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var items = body.GetProperty("Items");
|
||||
items.GetArrayLength().ShouldBe(2);
|
||||
// Most recent upgrade (item 2) should be first
|
||||
items[0].GetProperty("NewScore").GetInt32().ShouldBe(300);
|
||||
items[1].GetProperty("NewScore").GetInt32().ShouldBe(200);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetStats Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_WithNoEntries_ReturnsZeroes()
|
||||
{
|
||||
var result = await _controller.GetStats();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
|
||||
|
||||
stats.TotalTracked.ShouldBe(0);
|
||||
stats.BelowCutoff.ShouldBe(0);
|
||||
stats.AtOrAboveCutoff.ShouldBe(0);
|
||||
stats.RecentUpgrades.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_CorrectlyCategorizesBelowAndAboveCutoff()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 2, "At", currentScore: 500, cutoffScore: 500);
|
||||
AddScoreEntry(radarr.Id, 3, "Above", currentScore: 600, cutoffScore: 500);
|
||||
|
||||
var result = await _controller.GetStats();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
|
||||
|
||||
stats.TotalTracked.ShouldBe(3);
|
||||
stats.BelowCutoff.ShouldBe(1);
|
||||
stats.AtOrAboveCutoff.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_CountsRecentUpgradesFromLast7Days()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
AddScoreEntry(radarr.Id, 1, "Movie", currentScore: 300, cutoffScore: 500);
|
||||
|
||||
// Upgrade within 7 days
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 1, score: 300, recordedAt: DateTime.UtcNow.AddDays(-1));
|
||||
|
||||
// Upgrade outside 7 days (should not be counted)
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 50, recordedAt: DateTime.UtcNow.AddDays(-20));
|
||||
AddHistoryEntry(radarr.Id, externalItemId: 2, score: 200, recordedAt: DateTime.UtcNow.AddDays(-15));
|
||||
|
||||
var result = await _controller.GetStats();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var stats = okResult.Value.ShouldBeOfType<CustomFormatScoreStatsResponse>();
|
||||
|
||||
stats.RecentUpgrades.ShouldBe(1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void AddScoreEntry(
|
||||
Guid arrInstanceId,
|
||||
long externalItemId,
|
||||
string title,
|
||||
int currentScore,
|
||||
int cutoffScore,
|
||||
InstanceType itemType = InstanceType.Radarr,
|
||||
DateTime? lastSynced = null)
|
||||
{
|
||||
_dataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
EpisodeId = 0,
|
||||
ItemType = itemType,
|
||||
Title = title,
|
||||
FileId = externalItemId * 10,
|
||||
CurrentScore = currentScore,
|
||||
CutoffScore = cutoffScore,
|
||||
QualityProfileName = "HD",
|
||||
LastSyncedAt = lastSynced ?? DateTime.UtcNow
|
||||
});
|
||||
_dataContext.SaveChanges();
|
||||
}
|
||||
|
||||
private void AddHistoryEntry(
|
||||
Guid arrInstanceId,
|
||||
long externalItemId,
|
||||
int score,
|
||||
DateTime recordedAt,
|
||||
long episodeId = 0,
|
||||
int cutoffScore = 500,
|
||||
InstanceType itemType = InstanceType.Radarr)
|
||||
{
|
||||
_dataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
EpisodeId = episodeId,
|
||||
ItemType = itemType,
|
||||
Title = $"Item {externalItemId}",
|
||||
Score = score,
|
||||
CutoffScore = cutoffScore,
|
||||
RecordedAt = recordedAt
|
||||
});
|
||||
_dataContext.SaveChanges();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
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.Features.Seeker;
|
||||
|
||||
public class SearchStatsControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly SearchStatsController _controller;
|
||||
|
||||
public SearchStatsControllerTests()
|
||||
{
|
||||
_dataContext = SeekerTestDataFactory.CreateDataContext();
|
||||
_eventsContext = SeekerTestDataFactory.CreateEventsContext();
|
||||
_controller = new SearchStatsController(_dataContext, _eventsContext);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_eventsContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static JsonElement GetResponseBody(IActionResult result)
|
||||
{
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var json = JsonSerializer.Serialize(okResult.Value);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
|
||||
#region ParseEventData (tested via GetEvents)
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithNullEventData_ReturnsUnknownDefaults()
|
||||
{
|
||||
AddSearchEvent(data: null);
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("InstanceName").GetString().ShouldBe("Unknown");
|
||||
item.GetProperty("ItemCount").GetInt32().ShouldBe(0);
|
||||
item.GetProperty("Items").GetArrayLength().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithValidFullJson_ParsesAllFields()
|
||||
{
|
||||
var data = JsonSerializer.Serialize(new
|
||||
{
|
||||
InstanceName = "My Radarr",
|
||||
ItemCount = 3,
|
||||
Items = new[] { "Movie A", "Movie B", "Movie C" },
|
||||
SearchType = "Proactive",
|
||||
GrabbedItems = new[] { new { Title = "Movie A", Quality = "Bluray-1080p" } }
|
||||
});
|
||||
AddSearchEvent(data: data);
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("InstanceName").GetString().ShouldBe("My Radarr");
|
||||
item.GetProperty("ItemCount").GetInt32().ShouldBe(3);
|
||||
item.GetProperty("Items").GetArrayLength().ShouldBe(3);
|
||||
item.GetProperty("Items")[0].GetString().ShouldBe("Movie A");
|
||||
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Proactive));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithPartialJson_ReturnsDefaultsForMissingFields()
|
||||
{
|
||||
// Only InstanceName is present, other fields missing
|
||||
var data = JsonSerializer.Serialize(new { InstanceName = "Partial Instance" });
|
||||
AddSearchEvent(data: data);
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("InstanceName").GetString().ShouldBe("Partial Instance");
|
||||
item.GetProperty("ItemCount").GetInt32().ShouldBe(0);
|
||||
item.GetProperty("Items").GetArrayLength().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithMalformedJson_ReturnsUnknownDefaults()
|
||||
{
|
||||
AddSearchEvent(data: "not valid json {{{");
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("InstanceName").GetString().ShouldBe("Unknown");
|
||||
item.GetProperty("ItemCount").GetInt32().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchTypeReplacement_ParsesCorrectEnum()
|
||||
{
|
||||
var data = JsonSerializer.Serialize(new
|
||||
{
|
||||
InstanceName = "Sonarr",
|
||||
SearchType = "Replacement"
|
||||
});
|
||||
AddSearchEvent(data: data);
|
||||
|
||||
var result = await _controller.GetEvents();
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
var item = body.GetProperty("Items")[0];
|
||||
item.GetProperty("SearchType").GetString().ShouldBe(nameof(SeekerSearchType.Replacement));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetEvents Filtering
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithInstanceIdFilter_FiltersViaInstanceUrl()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
|
||||
// Event matching radarr's URL
|
||||
AddSearchEvent(instanceUrl: radarr.Url.ToString(), instanceType: InstanceType.Radarr,
|
||||
data: JsonSerializer.Serialize(new { InstanceName = "Radarr Event" }));
|
||||
// Event matching sonarr's URL
|
||||
AddSearchEvent(instanceUrl: sonarr.Url.ToString(), instanceType: InstanceType.Sonarr,
|
||||
data: JsonSerializer.Serialize(new { InstanceName = "Sonarr Event" }));
|
||||
|
||||
var result = await _controller.GetEvents(instanceId: radarr.Id);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("InstanceName").GetString().ShouldBe("Radarr Event");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithCycleIdFilter_ReturnsOnlyMatchingCycle()
|
||||
{
|
||||
var cycleA = Guid.NewGuid();
|
||||
var cycleB = Guid.NewGuid();
|
||||
|
||||
AddSearchEvent(cycleId: cycleA, data: JsonSerializer.Serialize(new { InstanceName = "Cycle A" }));
|
||||
AddSearchEvent(cycleId: cycleB, data: JsonSerializer.Serialize(new { InstanceName = "Cycle B" }));
|
||||
|
||||
var result = await _controller.GetEvents(cycleId: cycleA);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
body.GetProperty("Items")[0].GetProperty("InstanceName").GetString().ShouldBe("Cycle A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithSearchFilter_FiltersOnDataField()
|
||||
{
|
||||
AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = "Radarr", Items = new[] { "The Matrix" } }));
|
||||
AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = "Sonarr", Items = new[] { "Breaking Bad" } }));
|
||||
|
||||
var result = await _controller.GetEvents(search: "matrix");
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvents_WithPagination_ReturnsCorrectPageAndCount()
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
AddSearchEvent(data: JsonSerializer.Serialize(new { InstanceName = $"Event {i}" }));
|
||||
}
|
||||
|
||||
var result = await _controller.GetEvents(page: 2, pageSize: 2);
|
||||
var body = GetResponseBody(result);
|
||||
|
||||
body.GetProperty("TotalCount").GetInt32().ShouldBe(5);
|
||||
body.GetProperty("TotalPages").GetInt32().ShouldBe(3); // ceil(5/2) = 3
|
||||
body.GetProperty("Page").GetInt32().ShouldBe(2);
|
||||
body.GetProperty("Items").GetArrayLength().ShouldBe(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void AddSearchEvent(
|
||||
string? data = null,
|
||||
string? instanceUrl = null,
|
||||
InstanceType? instanceType = null,
|
||||
Guid? cycleId = null,
|
||||
SearchCommandStatus? searchStatus = null)
|
||||
{
|
||||
_eventsContext.Events.Add(new AppEvent
|
||||
{
|
||||
EventType = EventType.SearchTriggered,
|
||||
Message = "Search triggered",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = data,
|
||||
InstanceUrl = instanceUrl,
|
||||
InstanceType = instanceType,
|
||||
CycleId = cycleId,
|
||||
SearchStatus = searchStatus,
|
||||
Timestamp = DateTime.UtcNow
|
||||
});
|
||||
_eventsContext.SaveChanges();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Shouldly;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker;
|
||||
|
||||
public class SeekerConfigControllerTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly ILogger<SeekerConfigController> _logger;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
private readonly SeekerConfigController _controller;
|
||||
|
||||
public SeekerConfigControllerTests()
|
||||
{
|
||||
_dataContext = SeekerTestDataFactory.CreateDataContext();
|
||||
_logger = Substitute.For<ILogger<SeekerConfigController>>();
|
||||
_jobManagementService = Substitute.For<IJobManagementService>();
|
||||
_controller = new SeekerConfigController(_logger, _dataContext, _jobManagementService);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
#region GetSeekerConfig Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_WithNoSeekerInstanceConfigs_ReturnsDefaults()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
|
||||
var result = await _controller.GetSeekerConfig();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
|
||||
|
||||
var instance = response.Instances.ShouldHaveSingleItem();
|
||||
instance.ArrInstanceId.ShouldBe(radarr.Id);
|
||||
instance.Enabled.ShouldBeFalse();
|
||||
instance.SkipTags.ShouldBeEmpty();
|
||||
instance.ActiveDownloadLimit.ShouldBe(3);
|
||||
instance.MinCycleTimeDays.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSeekerConfig_OnlyReturnsSonarrAndRadarrInstances()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
var lidarr = SeekerTestDataFactory.AddLidarrInstance(_dataContext);
|
||||
|
||||
var result = await _controller.GetSeekerConfig();
|
||||
var okResult = result.ShouldBeOfType<OkObjectResult>();
|
||||
var response = okResult.Value.ShouldBeOfType<SeekerConfigResponse>();
|
||||
|
||||
response.Instances.Count.ShouldBe(2);
|
||||
response.Instances.ShouldContain(i => i.ArrInstanceId == radarr.Id);
|
||||
response.Instances.ShouldContain(i => i.ArrInstanceId == sonarr.Id);
|
||||
response.Instances.ShouldNotContain(i => i.ArrInstanceId == lidarr.Id);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateSeekerConfig Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WithProactiveEnabledAndNoInstancesEnabled_ThrowsValidationException()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 5,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = false // No instances enabled
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<ValidationException>(() => _controller.UpdateSeekerConfig(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenIntervalChanges_ReschedulesSeeker()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Default interval is 3, change to 5
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 5,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StartJob(JobType.Seeker, null, Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenIntervalUnchanged_DoesNotReschedule()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Keep interval at default (3)
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.DidNotReceive()
|
||||
.StartJob(Arg.Any<JobType>(), null, Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenCustomFormatScoreEnabled_StartsAndTriggersSyncerJob()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// UseCustomFormatScore was false (default), now enable it
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
UseCustomFormatScore = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StartJob(JobType.CustomFormatScoreSyncer, null, Arg.Any<string>());
|
||||
await _jobManagementService.Received(1)
|
||||
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenCustomFormatScoreDisabled_StopsSyncerJob()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// First enable CF score
|
||||
var config = await _dataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Now disable it
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
UseCustomFormatScore = false,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.StopJob(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_WhenSearchReenabledWithCustomFormatActive_TriggersSyncerOnce()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Set up state: CF score already enabled, search currently disabled
|
||||
var config = await _dataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
config.SearchEnabled = false;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Re-enable search
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = false,
|
||||
UseCustomFormatScore = true,
|
||||
Instances =
|
||||
[
|
||||
new UpdateSeekerInstanceConfigRequest { ArrInstanceId = radarr.Id, Enabled = true }
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
await _jobManagementService.Received(1)
|
||||
.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateSeekerConfig_SyncsExistingAndCreatesNewInstanceConfigs()
|
||||
{
|
||||
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
|
||||
var sonarr = SeekerTestDataFactory.AddSonarrInstance(_dataContext);
|
||||
|
||||
// Radarr already has a config
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = false,
|
||||
SkipTags = ["old-tag"],
|
||||
ActiveDownloadLimit = 2,
|
||||
MinCycleTimeDays = 5
|
||||
});
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = new UpdateSeekerConfigRequest
|
||||
{
|
||||
SearchEnabled = true,
|
||||
SearchInterval = 3,
|
||||
ProactiveSearchEnabled = true,
|
||||
Instances =
|
||||
[
|
||||
// Update existing radarr config
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
ArrInstanceId = radarr.Id,
|
||||
Enabled = true,
|
||||
SkipTags = ["new-tag"],
|
||||
ActiveDownloadLimit = 5,
|
||||
MinCycleTimeDays = 14
|
||||
},
|
||||
// Create new sonarr config
|
||||
new UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
ArrInstanceId = sonarr.Id,
|
||||
Enabled = true,
|
||||
SkipTags = ["sonarr-tag"],
|
||||
ActiveDownloadLimit = 3,
|
||||
MinCycleTimeDays = 7
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await _controller.UpdateSeekerConfig(request);
|
||||
|
||||
var configs = await _dataContext.SeekerInstanceConfigs.ToListAsync();
|
||||
configs.Count.ShouldBe(2);
|
||||
|
||||
var radarrConfig = configs.First(c => c.ArrInstanceId == radarr.Id);
|
||||
radarrConfig.Enabled.ShouldBeTrue();
|
||||
radarrConfig.SkipTags.ShouldContain("new-tag");
|
||||
radarrConfig.ActiveDownloadLimit.ShouldBe(5);
|
||||
radarrConfig.MinCycleTimeDays.ShouldBe(14);
|
||||
|
||||
var sonarrConfig = configs.First(c => c.ArrInstanceId == sonarr.Id);
|
||||
sonarrConfig.Enabled.ShouldBeTrue();
|
||||
sonarrConfig.SkipTags.ShouldContain("sonarr-tag");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating SQLite in-memory contexts for Seeker controller tests
|
||||
/// </summary>
|
||||
public static class SeekerTestDataFactory
|
||||
{
|
||||
public static DataContext CreateDataContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
var context = new DataContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
|
||||
SeedDefaultData(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
public static EventsContext CreateEventsContext()
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
var context = new EventsContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
return context;
|
||||
}
|
||||
|
||||
private static void SeedDefaultData(DataContext context)
|
||||
{
|
||||
context.GeneralConfigs.Add(new GeneralConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
DryRun = false,
|
||||
IgnoredDownloads = [],
|
||||
Log = new LoggingConfig()
|
||||
});
|
||||
|
||||
context.ArrConfigs.AddRange(
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 },
|
||||
new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 }
|
||||
);
|
||||
|
||||
context.QueueCleanerConfigs.Add(new QueueCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
FailedImport = new FailedImportConfig()
|
||||
});
|
||||
|
||||
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
DeletePrivate = false,
|
||||
Sonarr = new BlocklistSettings { Enabled = false },
|
||||
Radarr = new BlocklistSettings { Enabled = false },
|
||||
Lidarr = new BlocklistSettings { Enabled = false },
|
||||
Readarr = new BlocklistSettings { Enabled = false },
|
||||
Whisparr = new BlocklistSettings { Enabled = false }
|
||||
});
|
||||
|
||||
context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IgnoredDownloads = [],
|
||||
Categories = [],
|
||||
UnlinkedEnabled = false,
|
||||
UnlinkedTargetCategory = "",
|
||||
UnlinkedCategories = []
|
||||
});
|
||||
|
||||
context.SeekerConfigs.Add(new SeekerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SearchEnabled = true,
|
||||
ProactiveSearchEnabled = false
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
public static ArrInstance AddSonarrInstance(DataContext context, bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Sonarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Sonarr",
|
||||
Url = new Uri("http://sonarr:8989"),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static ArrInstance AddRadarrInstance(DataContext context, bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Radarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Radarr",
|
||||
Url = new Uri("http://radarr:7878"),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static ArrInstance AddLidarrInstance(DataContext context, bool enabled = true)
|
||||
{
|
||||
var arrConfig = context.ArrConfigs.First(x => x.Type == InstanceType.Lidarr);
|
||||
var instance = new ArrInstance
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Lidarr",
|
||||
Url = new Uri("http://lidarr:8686"),
|
||||
ApiKey = "test-api-key",
|
||||
Enabled = enabled,
|
||||
ArrConfigId = arrConfig.Id,
|
||||
ArrConfig = arrConfig
|
||||
};
|
||||
|
||||
arrConfig.Instances.Add(instance);
|
||||
context.ArrInstances.Add(instance);
|
||||
context.SaveChanges();
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,13 @@ public class JobsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{jobType}/start")]
|
||||
public async Task<IActionResult> StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest = null)
|
||||
public async Task<IActionResult> StartJob(JobType jobType, [FromBody] ScheduleRequest scheduleRequest)
|
||||
{
|
||||
if (jobType == JobType.Seeker)
|
||||
{
|
||||
return BadRequest("The Seeker job cannot be manually controlled");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get the schedule from the request body if provided
|
||||
@@ -82,6 +87,11 @@ public class JobsController : ControllerBase
|
||||
[HttpPost("{jobType}/trigger")]
|
||||
public async Task<IActionResult> TriggerJob(JobType jobType)
|
||||
{
|
||||
if (jobType == JobType.Seeker)
|
||||
{
|
||||
return BadRequest("The Seeker job cannot be manually triggered");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _jobManagementService.TriggerJobOnce(jobType);
|
||||
@@ -102,6 +112,11 @@ public class JobsController : ControllerBase
|
||||
[HttpPut("{jobType}/schedule")]
|
||||
public async Task<IActionResult> UpdateJobSchedule(JobType jobType, [FromBody] ScheduleRequest scheduleRequest)
|
||||
{
|
||||
if (jobType == JobType.Seeker)
|
||||
{
|
||||
return BadRequest("The Seeker job schedule cannot be manually modified");
|
||||
}
|
||||
|
||||
if (scheduleRequest?.Schedule == null)
|
||||
{
|
||||
return BadRequest("Schedule is required");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
@@ -30,9 +29,6 @@ public static class MainDI
|
||||
|
||||
config.AddConsumer<DownloadRemoverConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadRemoverConsumer<SeriesSearchItem>>();
|
||||
config.AddConsumer<DownloadHunterConsumer<SearchItem>>();
|
||||
config.AddConsumer<DownloadHunterConsumer<SeriesSearchItem>>();
|
||||
|
||||
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
|
||||
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
|
||||
@@ -60,14 +56,6 @@ public static class MainDI
|
||||
e.PrefetchCount = 1;
|
||||
});
|
||||
|
||||
cfg.ReceiveEndpoint("download-hunter-queue", e =>
|
||||
{
|
||||
e.ConfigureConsumer<DownloadHunterConsumer<SearchItem>>(context);
|
||||
e.ConfigureConsumer<DownloadHunterConsumer<SeriesSearchItem>>(context);
|
||||
e.ConcurrentMessageLimit = 1;
|
||||
e.PrefetchCount = 1;
|
||||
});
|
||||
|
||||
cfg.ReceiveEndpoint("notification-queue", e =>
|
||||
{
|
||||
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
|
||||
|
||||
@@ -5,8 +5,6 @@ using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Auth;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Files;
|
||||
@@ -49,8 +47,9 @@ public static class ServicesDI
|
||||
.AddScoped<BlacklistSynchronizer>()
|
||||
.AddScoped<MalwareBlocker>()
|
||||
.AddScoped<DownloadCleaner>()
|
||||
.AddScoped<Seeker>()
|
||||
.AddScoped<CustomFormatScoreSyncer>()
|
||||
.AddScoped<IQueueItemRemover, QueueItemRemover>()
|
||||
.AddScoped<IDownloadHunter, DownloadHunter>()
|
||||
.AddScoped<IFilenameEvaluator, FilenameEvaluator>()
|
||||
.AddScoped<IHardLinkFileService, HardLinkFileService>()
|
||||
.AddScoped<IUnixHardLinkFileService, UnixHardLinkFileService>()
|
||||
@@ -67,5 +66,6 @@ public static class ServicesDI
|
||||
.AddSingleton<IBlocklistProvider, BlocklistProvider>()
|
||||
.AddSingleton(TimeProvider.System)
|
||||
.AddSingleton<AppStatusSnapshot>()
|
||||
.AddHostedService<AppStatusRefreshService>();
|
||||
.AddHostedService<AppStatusRefreshService>()
|
||||
.AddHostedService<SeekerCommandMonitor>();
|
||||
}
|
||||
@@ -2,7 +2,6 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
|
||||
using Cleanuparr.Infrastructure.Logging;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Serilog.Events;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
@@ -20,10 +19,6 @@ public sealed record UpdateGeneralConfigRequest
|
||||
|
||||
public CertificateValidationType HttpCertificateValidation { get; init; } = CertificateValidationType.Enabled;
|
||||
|
||||
public bool SearchEnabled { get; init; } = true;
|
||||
|
||||
public ushort SearchDelay { get; init; } = Constants.DefaultSearchDelaySeconds;
|
||||
|
||||
public bool StatusCheckEnabled { get; init; } = true;
|
||||
|
||||
public string EncryptionKey { get; init; } = Guid.NewGuid().ToString();
|
||||
@@ -43,8 +38,6 @@ public sealed record UpdateGeneralConfigRequest
|
||||
existingConfig.HttpMaxRetries = HttpMaxRetries;
|
||||
existingConfig.HttpTimeout = HttpTimeout;
|
||||
existingConfig.HttpCertificateValidation = HttpCertificateValidation;
|
||||
existingConfig.SearchEnabled = SearchEnabled;
|
||||
existingConfig.SearchDelay = SearchDelay;
|
||||
existingConfig.StatusCheckEnabled = StatusCheckEnabled;
|
||||
existingConfig.EncryptionKey = EncryptionKey;
|
||||
existingConfig.IgnoredDownloads = IgnoredDownloads;
|
||||
|
||||
@@ -80,10 +80,14 @@ public sealed class GeneralConfigController : ControllerBase
|
||||
.Where(d => !d.Strikes.Any())
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
var deletedHistory = await _dataContext.SeekerHistory
|
||||
.Where(h => h.IsDryRun)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
_logger.LogWarning(
|
||||
"Dry run disabled — purged dry-run data: {Strikes} strikes, {Events} events, {ManualEvents} manual events, {Items} orphaned download items removed",
|
||||
deletedStrikes, deletedEvents, deletedManualEvents, deletedItems);
|
||||
|
||||
"Dry run disabled — purged dry-run data: {Strikes} strikes, {Events} events, {ManualEvents} manual events, {Items} orphaned download items, {History} search history entries removed",
|
||||
deletedStrikes, deletedEvents, deletedManualEvents, deletedItems, deletedHistory);
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -17,4 +17,6 @@ public abstract record CreateNotificationProviderRequestBase
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
|
||||
public bool OnSearchTriggered { get; init; }
|
||||
}
|
||||
|
||||
@@ -17,4 +17,6 @@ public abstract record UpdateNotificationProviderRequestBase
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
|
||||
public bool OnSearchTriggered { get; init; }
|
||||
}
|
||||
|
||||
@@ -74,7 +74,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = p.OnSlowStrike,
|
||||
OnQueueItemDeleted = p.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = p.OnDownloadCleaned,
|
||||
OnCategoryChanged = p.OnCategoryChanged
|
||||
OnCategoryChanged = p.OnCategoryChanged,
|
||||
OnSearchTriggered = p.OnSearchTriggered
|
||||
},
|
||||
Configuration = p.Type switch
|
||||
{
|
||||
@@ -153,6 +154,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
NotifiarrConfiguration = notifiarrConfig
|
||||
};
|
||||
|
||||
@@ -223,6 +225,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
AppriseConfiguration = appriseConfig
|
||||
};
|
||||
|
||||
@@ -300,6 +303,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
NtfyConfiguration = ntfyConfig
|
||||
};
|
||||
|
||||
@@ -368,6 +372,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
TelegramConfiguration = telegramConfig
|
||||
};
|
||||
|
||||
@@ -447,6 +452,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
NotifiarrConfiguration = notifiarrConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -533,6 +539,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
AppriseConfiguration = appriseConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -622,6 +629,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
NtfyConfiguration = ntfyConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -705,6 +713,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
TelegramConfiguration = telegramConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -815,7 +824,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false
|
||||
},
|
||||
Configuration = notifiarrConfig
|
||||
};
|
||||
@@ -882,7 +892,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false
|
||||
},
|
||||
Configuration = appriseConfig
|
||||
};
|
||||
@@ -956,7 +967,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false
|
||||
},
|
||||
Configuration = ntfyConfig
|
||||
};
|
||||
@@ -1013,7 +1025,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false
|
||||
},
|
||||
Configuration = telegramConfig
|
||||
};
|
||||
@@ -1048,7 +1061,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = provider.OnSlowStrike,
|
||||
OnQueueItemDeleted = provider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = provider.OnDownloadCleaned,
|
||||
OnCategoryChanged = provider.OnCategoryChanged
|
||||
OnCategoryChanged = provider.OnCategoryChanged,
|
||||
OnSearchTriggered = provider.OnSearchTriggered
|
||||
},
|
||||
Configuration = provider.Type switch
|
||||
{
|
||||
@@ -1105,6 +1119,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
DiscordConfiguration = discordConfig
|
||||
};
|
||||
|
||||
@@ -1185,6 +1200,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
DiscordConfiguration = discordConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -1254,7 +1270,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false
|
||||
},
|
||||
Configuration = discordConfig
|
||||
};
|
||||
@@ -1325,6 +1342,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
PushoverConfiguration = pushoverConfig
|
||||
};
|
||||
|
||||
@@ -1412,6 +1430,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
PushoverConfiguration = pushoverConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -1495,7 +1514,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false
|
||||
},
|
||||
Configuration = pushoverConfig
|
||||
};
|
||||
@@ -1551,6 +1571,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = newProvider.OnSearchTriggered,
|
||||
GotifyConfiguration = gotifyConfig
|
||||
};
|
||||
|
||||
@@ -1631,6 +1652,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = updatedProvider.OnCategoryChanged,
|
||||
OnSearchTriggered = updatedProvider.OnSearchTriggered,
|
||||
GotifyConfiguration = gotifyConfig,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -1698,7 +1720,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
OnCategoryChanged = false,
|
||||
OnSearchTriggered = false
|
||||
},
|
||||
Configuration = gotifyConfig
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateSeekerConfigRequest
|
||||
{
|
||||
public bool SearchEnabled { get; init; } = true;
|
||||
|
||||
public ushort SearchInterval { get; init; } = 3;
|
||||
|
||||
public bool ProactiveSearchEnabled { get; init; }
|
||||
|
||||
public SelectionStrategy SelectionStrategy { get; init; } = SelectionStrategy.BalancedWeighted;
|
||||
|
||||
public bool MonitoredOnly { get; init; } = true;
|
||||
|
||||
public bool UseCutoff { get; init; }
|
||||
|
||||
public bool UseCustomFormatScore { get; init; }
|
||||
|
||||
public bool UseRoundRobin { get; init; } = true;
|
||||
|
||||
public int PostReleaseGraceHours { get; init; } = 6;
|
||||
|
||||
public List<UpdateSeekerInstanceConfigRequest> Instances { get; init; } = [];
|
||||
|
||||
public SeekerConfig ApplyTo(SeekerConfig config)
|
||||
{
|
||||
config.SearchEnabled = SearchEnabled;
|
||||
config.SearchInterval = SearchInterval;
|
||||
config.ProactiveSearchEnabled = ProactiveSearchEnabled;
|
||||
config.SelectionStrategy = SelectionStrategy;
|
||||
config.MonitoredOnly = MonitoredOnly;
|
||||
config.UseCutoff = UseCutoff;
|
||||
config.UseCustomFormatScore = UseCustomFormatScore;
|
||||
config.UseRoundRobin = UseRoundRobin;
|
||||
config.PostReleaseGraceHours = PostReleaseGraceHours;
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Requests;
|
||||
|
||||
public sealed record UpdateSeekerInstanceConfigRequest
|
||||
{
|
||||
public Guid ArrInstanceId { get; init; }
|
||||
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
public List<string> SkipTags { get; init; } = [];
|
||||
|
||||
public int ActiveDownloadLimit { get; init; } = 3;
|
||||
|
||||
public int MinCycleTimeDays { get; init; } = 7;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record CustomFormatScoreEntryResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid ArrInstanceId { get; init; }
|
||||
public long ExternalItemId { get; init; }
|
||||
public long EpisodeId { get; init; }
|
||||
public InstanceType ItemType { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public long FileId { get; init; }
|
||||
public int CurrentScore { get; init; }
|
||||
public int CutoffScore { get; init; }
|
||||
public string QualityProfileName { get; init; } = string.Empty;
|
||||
public bool IsBelowCutoff { get; init; }
|
||||
public bool IsMonitored { get; init; }
|
||||
public DateTime LastSyncedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record CustomFormatScoreHistoryEntryResponse
|
||||
{
|
||||
public int Score { get; init; }
|
||||
public int CutoffScore { get; init; }
|
||||
public DateTime RecordedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record CustomFormatScoreStatsResponse
|
||||
{
|
||||
public int TotalTracked { get; init; }
|
||||
public int BelowCutoff { get; init; }
|
||||
public int AtOrAboveCutoff { get; init; }
|
||||
public int Monitored { get; init; }
|
||||
public int Unmonitored { get; init; }
|
||||
public int RecentUpgrades { get; init; }
|
||||
public List<InstanceCfScoreStat> PerInstanceStats { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record InstanceCfScoreStat
|
||||
{
|
||||
public Guid InstanceId { get; init; }
|
||||
public string InstanceName { get; init; } = string.Empty;
|
||||
public string InstanceType { get; init; } = string.Empty;
|
||||
public int TotalTracked { get; init; }
|
||||
public int BelowCutoff { get; init; }
|
||||
public int AtOrAboveCutoff { get; init; }
|
||||
public int Monitored { get; init; }
|
||||
public int Unmonitored { get; init; }
|
||||
public int RecentUpgrades { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record CustomFormatScoreUpgradeResponse
|
||||
{
|
||||
public Guid ArrInstanceId { get; init; }
|
||||
public long ExternalItemId { get; init; }
|
||||
public long EpisodeId { get; init; }
|
||||
public InstanceType ItemType { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public int PreviousScore { get; init; }
|
||||
public int NewScore { get; init; }
|
||||
public int CutoffScore { get; init; }
|
||||
public DateTime UpgradedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record InstanceSearchStat
|
||||
{
|
||||
public Guid InstanceId { get; init; }
|
||||
public string InstanceName { get; init; } = string.Empty;
|
||||
public string InstanceType { get; init; } = string.Empty;
|
||||
public int ItemsTracked { get; init; }
|
||||
public int TotalSearchCount { get; init; }
|
||||
public DateTime? LastSearchedAt { get; init; }
|
||||
public DateTime? LastProcessedAt { get; init; }
|
||||
public Guid? CurrentCycleId { get; init; }
|
||||
public int CycleItemsSearched { get; init; }
|
||||
public int CycleItemsTotal { get; init; }
|
||||
public DateTime? CycleStartedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record SearchEventResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public DateTime Timestamp { get; init; }
|
||||
public string InstanceName { get; init; } = string.Empty;
|
||||
public string? InstanceType { get; init; }
|
||||
public int ItemCount { get; init; }
|
||||
public List<string> Items { get; init; } = [];
|
||||
public SeekerSearchType SearchType { get; init; }
|
||||
public SearchCommandStatus? SearchStatus { get; init; }
|
||||
public DateTime? CompletedAt { get; init; }
|
||||
public object? GrabbedItems { get; init; }
|
||||
public Guid? CycleId { get; init; }
|
||||
public bool IsDryRun { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record SearchStatsSummaryResponse
|
||||
{
|
||||
public int TotalSearchesAllTime { get; init; }
|
||||
public int SearchesLast7Days { get; init; }
|
||||
public int SearchesLast30Days { get; init; }
|
||||
public int UniqueItemsSearched { get; init; }
|
||||
public int PendingReplacementSearches { get; init; }
|
||||
public int EnabledInstances { get; init; }
|
||||
public List<InstanceSearchStat> PerInstanceStats { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record SeekerConfigResponse
|
||||
{
|
||||
public bool SearchEnabled { get; init; }
|
||||
|
||||
public ushort SearchInterval { get; init; }
|
||||
|
||||
public bool ProactiveSearchEnabled { get; init; }
|
||||
|
||||
public SelectionStrategy SelectionStrategy { get; init; }
|
||||
|
||||
public bool MonitoredOnly { get; init; }
|
||||
|
||||
public bool UseCutoff { get; init; }
|
||||
|
||||
public bool UseCustomFormatScore { get; init; }
|
||||
|
||||
public bool UseRoundRobin { get; init; }
|
||||
|
||||
public int PostReleaseGraceHours { get; init; }
|
||||
|
||||
public List<SeekerInstanceConfigResponse> Instances { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
|
||||
public sealed record SeekerInstanceConfigResponse
|
||||
{
|
||||
public Guid ArrInstanceId { get; init; }
|
||||
|
||||
public string InstanceName { get; init; } = string.Empty;
|
||||
|
||||
public InstanceType InstanceType { get; init; }
|
||||
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public List<string> SkipTags { get; init; } = [];
|
||||
|
||||
public DateTime? LastProcessedAt { get; init; }
|
||||
|
||||
public bool ArrInstanceEnabled { get; init; }
|
||||
|
||||
public int ActiveDownloadLimit { get; init; }
|
||||
|
||||
public int MinCycleTimeDays { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/seeker/cf-scores")]
|
||||
[Authorize]
|
||||
public sealed class CustomFormatScoreController : ControllerBase
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
|
||||
public CustomFormatScoreController(DataContext dataContext)
|
||||
{
|
||||
_dataContext = dataContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets current CF scores with pagination, optionally filtered by instance.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetCustomFormatScores(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] Guid? instanceId = null,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string sortBy = "title",
|
||||
[FromQuery] bool hideMet = false)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 50;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
var query = _dataContext.CustomFormatScoreEntries
|
||||
.AsNoTracking()
|
||||
.AsQueryable();
|
||||
|
||||
if (instanceId.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.ArrInstanceId == instanceId.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
query = query.Where(e => e.Title.ToLower().Contains(search.ToLower()));
|
||||
}
|
||||
|
||||
if (hideMet)
|
||||
{
|
||||
query = query.Where(e => e.CurrentScore < e.CutoffScore);
|
||||
}
|
||||
|
||||
int totalCount = await query.CountAsync();
|
||||
|
||||
var items = await (sortBy == "date"
|
||||
? query.OrderByDescending(e => e.LastSyncedAt)
|
||||
: query.OrderBy(e => e.Title))
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(e => new CustomFormatScoreEntryResponse
|
||||
{
|
||||
Id = e.Id,
|
||||
ArrInstanceId = e.ArrInstanceId,
|
||||
ExternalItemId = e.ExternalItemId,
|
||||
EpisodeId = e.EpisodeId,
|
||||
ItemType = e.ItemType,
|
||||
Title = e.Title,
|
||||
FileId = e.FileId,
|
||||
CurrentScore = e.CurrentScore,
|
||||
CutoffScore = e.CutoffScore,
|
||||
QualityProfileName = e.QualityProfileName,
|
||||
IsBelowCutoff = e.CurrentScore < e.CutoffScore,
|
||||
IsMonitored = e.IsMonitored,
|
||||
LastSyncedAt = e.LastSyncedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Items = items,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent CF score upgrades (where score improved in history).
|
||||
/// </summary>
|
||||
[HttpGet("upgrades")]
|
||||
public async Task<IActionResult> GetRecentUpgrades(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
[FromQuery] Guid? instanceId = null,
|
||||
[FromQuery] int days = 30)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 20;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
// Find history entries where a newer entry has a higher score than an older one
|
||||
// We group by item and look for score increases between consecutive records
|
||||
var query = _dataContext.CustomFormatScoreHistory
|
||||
.AsNoTracking()
|
||||
.AsQueryable();
|
||||
|
||||
if (instanceId.HasValue)
|
||||
{
|
||||
query = query.Where(h => h.ArrInstanceId == instanceId.Value);
|
||||
}
|
||||
|
||||
var allHistory = await query
|
||||
.Where(h => h.RecordedAt >= DateTime.UtcNow.AddDays(-days))
|
||||
.OrderByDescending(h => h.RecordedAt)
|
||||
.ToListAsync();
|
||||
|
||||
var upgrades = new List<CustomFormatScoreUpgradeResponse>();
|
||||
|
||||
// Group by (ArrInstanceId, ExternalItemId, EpisodeId) and find score increases
|
||||
var grouped = allHistory
|
||||
.GroupBy(h => new { h.ArrInstanceId, h.ExternalItemId, h.EpisodeId });
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var entries = group.OrderBy(h => h.RecordedAt).ToList();
|
||||
for (int i = 1; i < entries.Count; i++)
|
||||
{
|
||||
if (entries[i].Score > entries[i - 1].Score)
|
||||
{
|
||||
upgrades.Add(new CustomFormatScoreUpgradeResponse
|
||||
{
|
||||
ArrInstanceId = entries[i].ArrInstanceId,
|
||||
ExternalItemId = entries[i].ExternalItemId,
|
||||
EpisodeId = entries[i].EpisodeId,
|
||||
ItemType = entries[i].ItemType,
|
||||
Title = entries[i].Title,
|
||||
PreviousScore = entries[i - 1].Score,
|
||||
NewScore = entries[i].Score,
|
||||
CutoffScore = entries[i].CutoffScore,
|
||||
UpgradedAt = entries[i].RecordedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by most recent upgrade first
|
||||
upgrades = upgrades.OrderByDescending(u => u.UpgradedAt).ToList();
|
||||
|
||||
int totalCount = upgrades.Count;
|
||||
var paged = upgrades
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Items = paged,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("instances")]
|
||||
public async Task<IActionResult> GetInstances()
|
||||
{
|
||||
var instances = await _dataContext.CustomFormatScoreEntries
|
||||
.AsNoTracking()
|
||||
.Select(e => new { e.ArrInstanceId, e.ItemType })
|
||||
.Distinct()
|
||||
.Join(
|
||||
_dataContext.ArrInstances.AsNoTracking(),
|
||||
e => e.ArrInstanceId,
|
||||
a => a.Id,
|
||||
(e, a) => new
|
||||
{
|
||||
Id = e.ArrInstanceId,
|
||||
a.Name,
|
||||
e.ItemType,
|
||||
})
|
||||
.OrderBy(x => x.Name)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { Instances = instances });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets summary statistics for CF score tracking.
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
public async Task<IActionResult> GetStats()
|
||||
{
|
||||
var entries = await _dataContext.CustomFormatScoreEntries
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
int totalTracked = entries.Count;
|
||||
int belowCutoff = entries.Count(e => e.CurrentScore < e.CutoffScore);
|
||||
int atOrAboveCutoff = totalTracked - belowCutoff;
|
||||
int monitored = entries.Count(e => e.IsMonitored);
|
||||
int unmonitored = totalTracked - monitored;
|
||||
|
||||
// Count upgrades in the last 7 days
|
||||
var sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
|
||||
var recentHistory = await _dataContext.CustomFormatScoreHistory
|
||||
.AsNoTracking()
|
||||
.Where(h => h.RecordedAt >= sevenDaysAgo)
|
||||
.OrderBy(h => h.RecordedAt)
|
||||
.ToListAsync();
|
||||
|
||||
int recentUpgrades = 0;
|
||||
var recentGrouped = recentHistory
|
||||
.GroupBy(h => new { h.ArrInstanceId, h.ExternalItemId, h.EpisodeId });
|
||||
|
||||
foreach (var group in recentGrouped)
|
||||
{
|
||||
var ordered = group.OrderBy(h => h.RecordedAt).ToList();
|
||||
for (int i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
if (ordered[i].Score > ordered[i - 1].Score)
|
||||
recentUpgrades++;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-instance stats
|
||||
var instanceIds = entries.Select(e => e.ArrInstanceId).Distinct().ToList();
|
||||
var instances = await _dataContext.ArrInstances
|
||||
.AsNoTracking()
|
||||
.Include(a => a.ArrConfig)
|
||||
.Where(a => instanceIds.Contains(a.Id))
|
||||
.ToListAsync();
|
||||
|
||||
var perInstanceStats = instanceIds.Select(instanceId =>
|
||||
{
|
||||
var instanceEntries = entries.Where(e => e.ArrInstanceId == instanceId).ToList();
|
||||
int instTracked = instanceEntries.Count;
|
||||
int instBelow = instanceEntries.Count(e => e.CurrentScore < e.CutoffScore);
|
||||
int instMonitored = instanceEntries.Count(e => e.IsMonitored);
|
||||
|
||||
int instUpgrades = 0;
|
||||
var instHistory = recentGrouped
|
||||
.Where(g => g.Key.ArrInstanceId == instanceId);
|
||||
foreach (var group in instHistory)
|
||||
{
|
||||
var ordered = group.OrderBy(h => h.RecordedAt).ToList();
|
||||
for (int i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
if (ordered[i].Score > ordered[i - 1].Score)
|
||||
instUpgrades++;
|
||||
}
|
||||
}
|
||||
|
||||
var instance = instances.FirstOrDefault(a => a.Id == instanceId);
|
||||
return new InstanceCfScoreStat
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
InstanceName = instance?.Name ?? "Unknown",
|
||||
InstanceType = instance?.ArrConfig.Type.ToString() ?? "Unknown",
|
||||
TotalTracked = instTracked,
|
||||
BelowCutoff = instBelow,
|
||||
AtOrAboveCutoff = instTracked - instBelow,
|
||||
Monitored = instMonitored,
|
||||
Unmonitored = instTracked - instMonitored,
|
||||
RecentUpgrades = instUpgrades,
|
||||
};
|
||||
}).OrderBy(s => s.InstanceName).ToList();
|
||||
|
||||
return Ok(new CustomFormatScoreStatsResponse
|
||||
{
|
||||
TotalTracked = totalTracked,
|
||||
BelowCutoff = belowCutoff,
|
||||
AtOrAboveCutoff = atOrAboveCutoff,
|
||||
Monitored = monitored,
|
||||
Unmonitored = unmonitored,
|
||||
RecentUpgrades = recentUpgrades,
|
||||
PerInstanceStats = perInstanceStats,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets CF score history for a specific item.
|
||||
/// </summary>
|
||||
[HttpGet("{instanceId}/{itemId}/history")]
|
||||
public async Task<IActionResult> GetItemHistory(
|
||||
Guid instanceId,
|
||||
long itemId,
|
||||
[FromQuery] long episodeId = 0)
|
||||
{
|
||||
var history = await _dataContext.CustomFormatScoreHistory
|
||||
.AsNoTracking()
|
||||
.Where(h => h.ArrInstanceId == instanceId
|
||||
&& h.ExternalItemId == itemId
|
||||
&& h.EpisodeId == episodeId)
|
||||
.OrderByDescending(h => h.RecordedAt)
|
||||
.Select(h => new CustomFormatScoreHistoryEntryResponse
|
||||
{
|
||||
Score = h.Score,
|
||||
CutoffScore = h.CutoffScore,
|
||||
RecordedAt = h.RecordedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { Entries = history });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/seeker/search-stats")]
|
||||
[Authorize]
|
||||
public sealed class SearchStatsController : ControllerBase
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly EventsContext _eventsContext;
|
||||
|
||||
public SearchStatsController(DataContext dataContext, EventsContext eventsContext)
|
||||
{
|
||||
_dataContext = dataContext;
|
||||
_eventsContext = eventsContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregate search statistics across all instances.
|
||||
/// </summary>
|
||||
[HttpGet("summary")]
|
||||
public async Task<IActionResult> GetSummary()
|
||||
{
|
||||
DateTime sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
|
||||
DateTime thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
|
||||
|
||||
// Event counts from EventsContext
|
||||
var searchEvents = _eventsContext.Events
|
||||
.AsNoTracking()
|
||||
.Where(e => e.EventType == EventType.SearchTriggered);
|
||||
|
||||
int totalSearchesAllTime = await searchEvents.CountAsync();
|
||||
int searchesLast7Days = await searchEvents.CountAsync(e => e.Timestamp >= sevenDaysAgo);
|
||||
int searchesLast30Days = await searchEvents.CountAsync(e => e.Timestamp >= thirtyDaysAgo);
|
||||
|
||||
// History stats from DataContext
|
||||
int uniqueItemsSearched = await _dataContext.SeekerHistory
|
||||
.AsNoTracking()
|
||||
.Select(h => h.ExternalItemId)
|
||||
.Distinct()
|
||||
.CountAsync();
|
||||
|
||||
int pendingReplacementSearches = await _dataContext.SearchQueue.CountAsync();
|
||||
|
||||
// Per-instance stats
|
||||
List<SeekerInstanceConfig> instanceConfigs = await _dataContext.SeekerInstanceConfigs
|
||||
.AsNoTracking()
|
||||
.Include(s => s.ArrInstance)
|
||||
.ThenInclude(a => a.ArrConfig)
|
||||
.Where(s => s.Enabled && s.ArrInstance.Enabled)
|
||||
.ToListAsync();
|
||||
|
||||
var historyByInstance = await _dataContext.SeekerHistory
|
||||
.AsNoTracking()
|
||||
.GroupBy(h => h.ArrInstanceId)
|
||||
.Select(g => new
|
||||
{
|
||||
InstanceId = g.Key,
|
||||
ItemsTracked = g.Select(h => h.ExternalItemId).Distinct().Count(),
|
||||
LastSearchedAt = g.Max(h => h.LastSearchedAt),
|
||||
TotalSearchCount = g.Sum(h => h.SearchCount),
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
// Count items searched in current cycle per instance
|
||||
List<Guid> currentCycleIds = instanceConfigs.Select(ic => ic.CurrentCycleId).ToList();
|
||||
var cycleItemsByInstance = await _dataContext.SeekerHistory
|
||||
.AsNoTracking()
|
||||
.Where(h => currentCycleIds.Contains(h.CycleId))
|
||||
.GroupBy(h => h.ArrInstanceId)
|
||||
.Select(g => new
|
||||
{
|
||||
InstanceId = g.Key,
|
||||
CycleItemsSearched = g.Select(h => h.ExternalItemId).Distinct().Count(),
|
||||
CycleStartedAt = (DateTime?)g.Min(h => h.LastSearchedAt),
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var perInstanceStats = instanceConfigs.Select(ic =>
|
||||
{
|
||||
var history = historyByInstance.FirstOrDefault(h => h.InstanceId == ic.ArrInstanceId);
|
||||
var cycleProgress = cycleItemsByInstance.FirstOrDefault(c => c.InstanceId == ic.ArrInstanceId);
|
||||
return new InstanceSearchStat
|
||||
{
|
||||
InstanceId = ic.ArrInstanceId,
|
||||
InstanceName = ic.ArrInstance.Name,
|
||||
InstanceType = ic.ArrInstance.ArrConfig.Type.ToString(),
|
||||
ItemsTracked = history?.ItemsTracked ?? 0,
|
||||
TotalSearchCount = history?.TotalSearchCount ?? 0,
|
||||
LastSearchedAt = history?.LastSearchedAt,
|
||||
LastProcessedAt = ic.LastProcessedAt,
|
||||
CurrentCycleId = ic.CurrentCycleId,
|
||||
CycleItemsSearched = cycleProgress?.CycleItemsSearched ?? 0,
|
||||
CycleItemsTotal = ic.TotalEligibleItems,
|
||||
CycleStartedAt = cycleProgress?.CycleStartedAt,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return Ok(new SearchStatsSummaryResponse
|
||||
{
|
||||
TotalSearchesAllTime = totalSearchesAllTime,
|
||||
SearchesLast7Days = searchesLast7Days,
|
||||
SearchesLast30Days = searchesLast30Days,
|
||||
UniqueItemsSearched = uniqueItemsSearched,
|
||||
PendingReplacementSearches = pendingReplacementSearches,
|
||||
EnabledInstances = instanceConfigs.Count,
|
||||
PerInstanceStats = perInstanceStats,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets paginated search-triggered events with decoded data.
|
||||
/// Supports optional text search across item names in event data.
|
||||
/// </summary>
|
||||
[HttpGet("events")]
|
||||
public async Task<IActionResult> GetEvents(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] Guid? instanceId = null,
|
||||
[FromQuery] Guid? cycleId = null,
|
||||
[FromQuery] string? search = null)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 50;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
var query = _eventsContext.Events
|
||||
.AsNoTracking()
|
||||
.Where(e => e.EventType == EventType.SearchTriggered);
|
||||
|
||||
// Filter by instance URL if instanceId provided
|
||||
if (instanceId.HasValue)
|
||||
{
|
||||
var instance = await _dataContext.ArrInstances
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.Id == instanceId.Value);
|
||||
|
||||
if (instance is not null)
|
||||
{
|
||||
string url = (instance.ExternalUrl ?? instance.Url).ToString();
|
||||
query = query.Where(e => e.InstanceUrl == url);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by cycle ID
|
||||
if (cycleId.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.CycleId == cycleId.Value);
|
||||
}
|
||||
|
||||
// Pre-filter by search term on the JSON data field
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
query = query.Where(e => e.Data != null && e.Data.ToLower().Contains(search.ToLower()));
|
||||
}
|
||||
|
||||
int totalCount = await query.CountAsync();
|
||||
|
||||
var rawEvents = await query
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
var items = rawEvents.Select(e =>
|
||||
{
|
||||
var parsed = ParseEventData(e.Data);
|
||||
return new SearchEventResponse
|
||||
{
|
||||
Id = e.Id,
|
||||
Timestamp = e.Timestamp,
|
||||
InstanceName = parsed.InstanceName,
|
||||
InstanceType = e.InstanceType?.ToString(),
|
||||
ItemCount = parsed.ItemCount,
|
||||
Items = parsed.Items,
|
||||
SearchType = parsed.SearchType,
|
||||
SearchStatus = e.SearchStatus,
|
||||
CompletedAt = e.CompletedAt,
|
||||
GrabbedItems = parsed.GrabbedItems,
|
||||
CycleId = e.CycleId,
|
||||
IsDryRun = e.IsDryRun,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Items = items,
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
TotalCount = totalCount,
|
||||
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
private static (string InstanceName, int ItemCount, List<string> Items, SeekerSearchType SearchType, object? GrabbedItems) ParseEventData(string? data)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(data))
|
||||
{
|
||||
return ("Unknown", 0, [], SeekerSearchType.Proactive, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(data);
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
string instanceName = root.TryGetProperty("InstanceName", out var nameEl)
|
||||
? nameEl.GetString() ?? "Unknown"
|
||||
: "Unknown";
|
||||
|
||||
int itemCount = root.TryGetProperty("ItemCount", out var countEl)
|
||||
? countEl.GetInt32()
|
||||
: 0;
|
||||
|
||||
var items = new List<string>();
|
||||
if (root.TryGetProperty("Items", out var itemsEl) && itemsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement item in itemsEl.EnumerateArray())
|
||||
{
|
||||
string? val = item.GetString();
|
||||
if (val is not null) items.Add(val);
|
||||
}
|
||||
}
|
||||
|
||||
SeekerSearchType searchType = root.TryGetProperty("SearchType", out var typeEl)
|
||||
&& Enum.TryParse<SeekerSearchType>(typeEl.GetString(), out var parsed)
|
||||
? parsed
|
||||
: SeekerSearchType.Proactive;
|
||||
|
||||
object? grabbedItems = root.TryGetProperty("GrabbedItems", out var grabbedEl)
|
||||
? JsonSerializer.Deserialize<object>(grabbedEl.GetRawText())
|
||||
: null;
|
||||
|
||||
return (instanceName, itemCount, items, searchType, grabbedItems);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ("Unknown", 0, [], SeekerSearchType.Proactive, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Requests;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Cleanuparr.Api.Features.Seeker.Contracts.Responses;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Seeker.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/configuration")]
|
||||
[Authorize]
|
||||
public sealed class SeekerConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<SeekerConfigController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
|
||||
public SeekerConfigController(
|
||||
ILogger<SeekerConfigController> logger,
|
||||
DataContext dataContext,
|
||||
IJobManagementService jobManagementService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_jobManagementService = jobManagementService;
|
||||
}
|
||||
|
||||
[HttpGet("seeker")]
|
||||
public async Task<IActionResult> GetSeekerConfig()
|
||||
{
|
||||
var config = await _dataContext.SeekerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
// Get all Sonarr/Radarr instances with their seeker configs
|
||||
var arrInstances = await _dataContext.ArrInstances
|
||||
.AsNoTracking()
|
||||
.Include(a => a.ArrConfig)
|
||||
.Where(a => a.ArrConfig.Type == InstanceType.Sonarr || a.ArrConfig.Type == InstanceType.Radarr)
|
||||
.ToListAsync();
|
||||
|
||||
var arrInstanceIds = arrInstances.Select(a => a.Id).ToHashSet();
|
||||
var seekerInstanceConfigs = await _dataContext.SeekerInstanceConfigs
|
||||
.AsNoTracking()
|
||||
.Where(s => arrInstanceIds.Contains(s.ArrInstanceId))
|
||||
.ToListAsync();
|
||||
|
||||
var instanceResponses = arrInstances.Select(instance =>
|
||||
{
|
||||
var seekerConfig = seekerInstanceConfigs.FirstOrDefault(s => s.ArrInstanceId == instance.Id);
|
||||
return new SeekerInstanceConfigResponse
|
||||
{
|
||||
ArrInstanceId = instance.Id,
|
||||
InstanceName = instance.Name,
|
||||
InstanceType = instance.ArrConfig.Type,
|
||||
Enabled = seekerConfig?.Enabled ?? false,
|
||||
SkipTags = seekerConfig?.SkipTags ?? [],
|
||||
LastProcessedAt = seekerConfig?.LastProcessedAt,
|
||||
ArrInstanceEnabled = instance.Enabled,
|
||||
ActiveDownloadLimit = seekerConfig?.ActiveDownloadLimit ?? 3,
|
||||
MinCycleTimeDays = seekerConfig?.MinCycleTimeDays ?? 7,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
var response = new SeekerConfigResponse
|
||||
{
|
||||
SearchEnabled = config.SearchEnabled,
|
||||
SearchInterval = config.SearchInterval,
|
||||
ProactiveSearchEnabled = config.ProactiveSearchEnabled,
|
||||
SelectionStrategy = config.SelectionStrategy,
|
||||
MonitoredOnly = config.MonitoredOnly,
|
||||
UseCutoff = config.UseCutoff,
|
||||
UseCustomFormatScore = config.UseCustomFormatScore,
|
||||
UseRoundRobin = config.UseRoundRobin,
|
||||
PostReleaseGraceHours = config.PostReleaseGraceHours,
|
||||
Instances = instanceResponses,
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
[HttpPut("seeker")]
|
||||
public async Task<IActionResult> UpdateSeekerConfig([FromBody] UpdateSeekerConfigRequest request)
|
||||
{
|
||||
if (!await DataContext.Lock.WaitAsync(TimeSpan.FromSeconds(30)))
|
||||
{
|
||||
return StatusCode(503, "Database is busy, please try again");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var config = await _dataContext.SeekerConfigs.FirstAsync();
|
||||
|
||||
ushort previousInterval = config.SearchInterval;
|
||||
bool previousUseCustomFormatScore = config.UseCustomFormatScore;
|
||||
bool previousSearchEnabled = config.SearchEnabled;
|
||||
bool previousProactiveSearchEnabled = config.ProactiveSearchEnabled;
|
||||
|
||||
request.ApplyTo(config);
|
||||
config.Validate();
|
||||
|
||||
if (request.ProactiveSearchEnabled && !request.Instances.Any(i => i.Enabled))
|
||||
{
|
||||
throw new Domain.Exceptions.ValidationException(
|
||||
"At least one instance must be enabled when proactive search is enabled");
|
||||
}
|
||||
|
||||
// Sync instance configs
|
||||
var existingInstanceConfigs = await _dataContext.SeekerInstanceConfigs.ToListAsync();
|
||||
|
||||
foreach (var instanceReq in request.Instances)
|
||||
{
|
||||
var existing = existingInstanceConfigs
|
||||
.FirstOrDefault(e => e.ArrInstanceId == instanceReq.ArrInstanceId);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.Enabled = instanceReq.Enabled;
|
||||
existing.SkipTags = instanceReq.SkipTags;
|
||||
existing.ActiveDownloadLimit = instanceReq.ActiveDownloadLimit;
|
||||
existing.MinCycleTimeDays = instanceReq.MinCycleTimeDays;
|
||||
}
|
||||
else
|
||||
{
|
||||
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = instanceReq.ArrInstanceId,
|
||||
Enabled = instanceReq.Enabled,
|
||||
SkipTags = instanceReq.SkipTags,
|
||||
ActiveDownloadLimit = instanceReq.ActiveDownloadLimit,
|
||||
MinCycleTimeDays = instanceReq.MinCycleTimeDays,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Update Quartz trigger if SearchInterval changed
|
||||
if (config.SearchInterval != previousInterval)
|
||||
{
|
||||
_logger.LogInformation("Search interval changed from {Old} to {New} minutes, updating Seeker schedule",
|
||||
previousInterval, config.SearchInterval);
|
||||
await _jobManagementService.StartJob(JobType.Seeker, null, config.ToCronExpression());
|
||||
}
|
||||
|
||||
// Toggle CustomFormatScoreSyncer job when UseCustomFormatScore changes
|
||||
if (config.UseCustomFormatScore != previousUseCustomFormatScore)
|
||||
{
|
||||
if (config.UseCustomFormatScore)
|
||||
{
|
||||
_logger.LogInformation("UseCustomFormatScore enabled, starting CustomFormatScoreSyncer job");
|
||||
await _jobManagementService.StartJob(JobType.CustomFormatScoreSyncer, null, Constants.CustomFormatScoreSyncerCron);
|
||||
await _jobManagementService.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("UseCustomFormatScore disabled, stopping CustomFormatScoreSyncer job");
|
||||
await _jobManagementService.StopJob(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger CustomFormatScoreSyncer once when search or proactive search is re-enabled with custom format scores active
|
||||
if (previousUseCustomFormatScore && config.UseCustomFormatScore)
|
||||
{
|
||||
bool searchJustEnabled = !previousSearchEnabled && config.SearchEnabled;
|
||||
bool proactiveJustEnabled = !previousProactiveSearchEnabled && config.ProactiveSearchEnabled;
|
||||
|
||||
if (searchJustEnabled || proactiveJustEnabled)
|
||||
{
|
||||
_logger.LogInformation("Search re-enabled with UseCustomFormatScore active, triggering CustomFormatScoreSyncer");
|
||||
await _jobManagementService.TriggerJobOnce(JobType.CustomFormatScoreSyncer);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { Message = "Seeker configuration updated successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save Seeker configuration");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using SeekerJob = Cleanuparr.Infrastructure.Features.Jobs.Seeker;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Quartz;
|
||||
@@ -100,12 +102,17 @@ public class BackgroundJobManager : IHostedService
|
||||
BlacklistSyncConfig blacklistSyncConfig = await dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
|
||||
SeekerConfig seekerConfig = await dataContext.SeekerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync(cancellationToken);
|
||||
|
||||
// Always register jobs, regardless of enabled status
|
||||
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
|
||||
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
|
||||
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
|
||||
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
|
||||
await RegisterSeekerJob(seekerConfig, cancellationToken);
|
||||
await RegisterCustomFormatScoreSyncJob(seekerConfig, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -171,6 +178,30 @@ public class BackgroundJobManager : IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the Seeker job with a trigger based on SearchInterval.
|
||||
/// The Seeker is always running.
|
||||
/// </summary>
|
||||
public async Task RegisterSeekerJob(SeekerConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AddJobWithoutTrigger<SeekerJob>(cancellationToken);
|
||||
await AddTriggersForJob<SeekerJob>(config.ToCronExpression(), cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the CustomFormatScoreSyncer job. Only adds triggers when UseCustomFormatScore is enabled.
|
||||
/// Runs every 30 minutes to sync custom format scores from arr instances.
|
||||
/// </summary>
|
||||
public async Task RegisterCustomFormatScoreSyncJob(SeekerConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AddJobWithoutTrigger<CustomFormatScoreSyncer>(cancellationToken);
|
||||
|
||||
if (config.UseCustomFormatScore)
|
||||
{
|
||||
await AddTriggersForJob<CustomFormatScoreSyncer>(Constants.CustomFormatScoreSyncerCron, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to add triggers for an existing job.
|
||||
/// </summary>
|
||||
@@ -204,7 +235,11 @@ public class BackgroundJobManager : IHostedService
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
|
||||
}
|
||||
|
||||
if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit)
|
||||
if (typeof(T) == typeof(SeekerJob) && triggerValue < Constants.SeekerMinLimit)
|
||||
{
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.SeekerMinLimit.TotalMinutes} minutes");
|
||||
}
|
||||
else if (typeof(T) != typeof(MalwareBlocker) && triggerValue < Constants.TriggerMinLimit)
|
||||
{
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class GenericJob<T> : IJob
|
||||
await BroadcastJobStatus(hubContext, jobManagementService, jobType, false);
|
||||
|
||||
var handler = scope.ServiceProvider.GetRequiredService<T>();
|
||||
await handler.ExecuteAsync();
|
||||
await handler.ExecuteAsync(context.CancellationToken);
|
||||
|
||||
status = JobRunStatus.Completed;
|
||||
await BroadcastJobStatus(hubContext, jobManagementService, jobType, true);
|
||||
|
||||
@@ -38,7 +38,7 @@ public sealed class BlacklistSynchronizer : IHandler
|
||||
_dryRunInterceptor = dryRunInterceptor;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync()
|
||||
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
@@ -73,7 +73,7 @@ public sealed class BlacklistSynchronizer : IHandler
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Enabled && c.TypeName == DownloadClientTypeName.qBittorrent)
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
if (qBittorrentClients.Count is 0)
|
||||
{
|
||||
_logger.LogDebug("No enabled qBittorrent clients found for blacklist sync");
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record ArrCommandStatus(long Id, string Status, string? Message);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record ArrEpisodeFile
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
public bool QualityCutoffNotMet { get; init; }
|
||||
|
||||
public int CustomFormatScore { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record ArrQualityProfile
|
||||
{
|
||||
public int Id { get; init; }
|
||||
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public int CutoffFormatScore { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the custom format score data from a movie/episode file API response
|
||||
/// </summary>
|
||||
public sealed record MediaFileScore
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
public int CustomFormatScore { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record MovieFileInfo
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
public bool QualityCutoffNotMet { get; init; }
|
||||
}
|
||||
@@ -37,4 +37,5 @@ public sealed record QueueRecord
|
||||
public required string DownloadId { get; init; }
|
||||
public required string Protocol { get; init; }
|
||||
public required long Id { get; init; }
|
||||
public long SizeLeft { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record SearchableEpisode
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
public int SeasonNumber { get; init; }
|
||||
|
||||
public int EpisodeNumber { get; init; }
|
||||
|
||||
public bool Monitored { get; init; }
|
||||
|
||||
public DateTime? AirDateUtc { get; init; }
|
||||
|
||||
public bool HasFile { get; init; }
|
||||
|
||||
public long EpisodeFileId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record SearchableMovie
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
public bool Monitored { get; init; }
|
||||
|
||||
public bool HasFile { get; init; }
|
||||
|
||||
public MovieFileInfo? MovieFile { get; init; }
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
|
||||
public int QualityProfileId { get; init; }
|
||||
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
public DateTime? Added { get; init; }
|
||||
|
||||
public DateTime? DigitalRelease { get; init; }
|
||||
|
||||
public DateTime? PhysicalRelease { get; init; }
|
||||
|
||||
public DateTime? InCinemas { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Cleanuparr.Domain.Entities.Arr;
|
||||
|
||||
public sealed record SearchableSeries
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
public int QualityProfileId { get; init; }
|
||||
|
||||
public bool Monitored { get; init; }
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
|
||||
public DateTime? Added { get; init; }
|
||||
|
||||
public string Status { get; init; } = string.Empty;
|
||||
|
||||
public SeriesStatistics? Statistics { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SeriesStatistics
|
||||
{
|
||||
public int EpisodeFileCount { get; init; }
|
||||
|
||||
public int EpisodeCount { get; init; }
|
||||
|
||||
public double PercentOfEpisodes { get; init; }
|
||||
}
|
||||
@@ -10,5 +10,6 @@ public enum EventType
|
||||
QueueItemDeleted,
|
||||
DownloadCleaned,
|
||||
CategoryChanged,
|
||||
DownloadMarkedForDeletion
|
||||
DownloadMarkedForDeletion,
|
||||
SearchTriggered,
|
||||
}
|
||||
@@ -6,4 +6,6 @@ public enum JobType
|
||||
MalwareBlocker,
|
||||
DownloadCleaner,
|
||||
BlacklistSynchronizer,
|
||||
Seeker,
|
||||
CustomFormatScoreSyncer,
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ public enum NotificationEventType
|
||||
SlowTimeStrike,
|
||||
QueueItemDeleted,
|
||||
DownloadCleaned,
|
||||
CategoryChanged
|
||||
CategoryChanged,
|
||||
SearchTriggered
|
||||
}
|
||||
|
||||
13
code/backend/Cleanuparr.Domain/Enums/SearchCommandStatus.cs
Normal file
13
code/backend/Cleanuparr.Domain/Enums/SearchCommandStatus.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SearchCommandStatus
|
||||
{
|
||||
Pending,
|
||||
Started,
|
||||
Completed,
|
||||
Failed,
|
||||
TimedOut
|
||||
}
|
||||
10
code/backend/Cleanuparr.Domain/Enums/SeekerSearchType.cs
Normal file
10
code/backend/Cleanuparr.Domain/Enums/SeekerSearchType.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SeekerSearchType
|
||||
{
|
||||
Proactive,
|
||||
Replacement
|
||||
}
|
||||
43
code/backend/Cleanuparr.Domain/Enums/SelectionStrategy.cs
Normal file
43
code/backend/Cleanuparr.Domain/Enums/SelectionStrategy.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum SelectionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Weighted random selection combining search recency and add date.
|
||||
/// Items that are both recently added and haven't been searched
|
||||
/// get the highest priority. Best all-around strategy for mixed libraries.
|
||||
/// </summary>
|
||||
BalancedWeighted,
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic selection of items with the oldest (or no) search history first.
|
||||
/// Provides systematic, sequential coverage of your entire library.
|
||||
/// </summary>
|
||||
OldestSearchFirst,
|
||||
|
||||
/// <summary>
|
||||
/// Weighted random selection based on search recency.
|
||||
/// Items that haven't been searched recently are ranked higher and more likely to be selected,
|
||||
/// while recently-searched items still have a chance proportional to their rank.
|
||||
/// </summary>
|
||||
OldestSearchWeighted,
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic selection of the most recently added items first.
|
||||
/// Always picks the newest content in your library.
|
||||
/// </summary>
|
||||
NewestFirst,
|
||||
|
||||
/// <summary>
|
||||
/// Weighted random selection based on when items were added.
|
||||
/// Recently added items are ranked higher and more likely to be selected,
|
||||
/// while older items still have a chance proportional to their rank.
|
||||
/// </summary>
|
||||
NewestWeighted,
|
||||
|
||||
/// <summary>
|
||||
/// Pure random selection with no weighting or bias.
|
||||
/// Every eligible item has an equal chance of being selected.
|
||||
/// </summary>
|
||||
Random,
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum SeriesSearchType
|
||||
{
|
||||
Episode,
|
||||
Season,
|
||||
Series
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,8 @@ public sealed class ValidationException : Exception
|
||||
public ValidationException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public ValidationException(string message, Exception inner) : base(message, inner)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
@@ -578,4 +579,220 @@ public class EventPublisherTests : IDisposable
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishSearchTriggered Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SavesEventWithCorrectType()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 2, ["Movie A", "Movie B"], SeekerSearchType.Proactive);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(EventType.SearchTriggered, savedEvent.EventType);
|
||||
Assert.Equal(EventSeverity.Information, savedEvent.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SetsSearchStatusToPending()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(SearchCommandStatus.Pending, savedEvent.SearchStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SetsCycleId()
|
||||
{
|
||||
// Arrange
|
||||
var cycleId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive, cycleId);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Equal(cycleId, savedEvent.CycleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_ReturnsEventId()
|
||||
{
|
||||
// Act
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(Guid.Empty, eventId);
|
||||
var savedEvent = await _context.Events.FindAsync(eventId);
|
||||
Assert.NotNull(savedEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SerializesItemsAndSearchTypeToData()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Sonarr-1", 2, ["Series A", "Series B"], SeekerSearchType.Replacement);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.NotNull(savedEvent.Data);
|
||||
Assert.Contains("Series A", savedEvent.Data);
|
||||
Assert.Contains("Series B", savedEvent.Data);
|
||||
Assert.Contains("Replacement", savedEvent.Data);
|
||||
Assert.Contains("Sonarr-1", savedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_NotifiesSignalRClients()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
|
||||
// Assert
|
||||
_clientProxyMock.Verify(c => c.SendCoreAsync(
|
||||
"EventReceived",
|
||||
It.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_SendsNotification()
|
||||
{
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 2, ["Movie A", "Movie B"], SeekerSearchType.Proactive);
|
||||
|
||||
// Assert
|
||||
_notificationPublisherMock.Verify(
|
||||
n => n.NotifySearchTriggered("Radarr-1", 2, It.IsAny<IEnumerable<string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchTriggered_TruncatesDisplayForMoreThan5Items()
|
||||
{
|
||||
// Arrange
|
||||
var items = new[] { "Item 1", "Item 2", "Item 3", "Item 4", "Item 5", "Item 6", "Item 7" };
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchTriggered("Radarr-1", 7, items, SeekerSearchType.Proactive);
|
||||
|
||||
// Assert
|
||||
var savedEvent = await _context.Events.FirstOrDefaultAsync();
|
||||
Assert.NotNull(savedEvent);
|
||||
Assert.Contains("+2 more", savedEvent.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PublishSearchCompleted Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_UpdatesEventStatus()
|
||||
{
|
||||
// Arrange — create a search event first
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
|
||||
|
||||
// Assert
|
||||
var updatedEvent = await _context.Events.FindAsync(eventId);
|
||||
Assert.NotNull(updatedEvent);
|
||||
Assert.Equal(SearchCommandStatus.Completed, updatedEvent.SearchStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_SetsCompletedAt()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
|
||||
|
||||
// Assert
|
||||
var updatedEvent = await _context.Events.FindAsync(eventId);
|
||||
Assert.NotNull(updatedEvent);
|
||||
Assert.NotNull(updatedEvent.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_MergesResultDataIntoExistingData()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
|
||||
var resultData = new { GrabbedItems = new[] { new { Title = "Movie A (2024)", Status = "downloading" } } };
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, resultData);
|
||||
|
||||
// Assert
|
||||
var updatedEvent = await _context.Events.FindAsync(eventId);
|
||||
Assert.NotNull(updatedEvent);
|
||||
Assert.NotNull(updatedEvent.Data);
|
||||
// Original data should still be present
|
||||
Assert.Contains("Movie A", updatedEvent.Data);
|
||||
// Merged result data should be present
|
||||
Assert.Contains("GrabbedItems", updatedEvent.Data);
|
||||
Assert.Contains("Movie A (2024)", updatedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_WithNullResultData_DoesNotModifyData()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
var originalEvent = await _context.Events.FindAsync(eventId);
|
||||
string? originalData = originalEvent!.Data;
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
|
||||
|
||||
// Assert
|
||||
var updatedEvent = await _context.Events.FindAsync(eventId);
|
||||
Assert.NotNull(updatedEvent);
|
||||
Assert.Equal(originalData, updatedEvent.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_EventNotFound_LogsWarningAndReturns()
|
||||
{
|
||||
// Act — use a non-existent event ID
|
||||
await _publisher.PublishSearchCompleted(Guid.NewGuid(), SearchCommandStatus.Completed);
|
||||
|
||||
// Assert — should not throw, and the log warning is the important behavior
|
||||
// (no exception thrown is the assertion)
|
||||
var eventCount = await _context.Events.CountAsync();
|
||||
Assert.Equal(0, eventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishSearchCompleted_NotifiesSignalRClients()
|
||||
{
|
||||
// Arrange
|
||||
Guid eventId = await _publisher.PublishSearchTriggered("Radarr-1", 1, ["Movie A"], SeekerSearchType.Proactive);
|
||||
|
||||
// Reset mock to only capture the completion call
|
||||
_clientProxyMock.Invocations.Clear();
|
||||
|
||||
// Act
|
||||
await _publisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed);
|
||||
|
||||
// Assert
|
||||
_clientProxyMock.Verify(c => c.SendCoreAsync(
|
||||
"EventReceived",
|
||||
It.Is<object[]>(args => args.Length == 1 && args[0] is AppEvent),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadHunter.Consumers;
|
||||
|
||||
public class DownloadHunterConsumerTests
|
||||
{
|
||||
private readonly Mock<ILogger<DownloadHunterConsumer<SearchItem>>> _loggerMock;
|
||||
private readonly Mock<IDownloadHunter> _downloadHunterMock;
|
||||
private readonly DownloadHunterConsumer<SearchItem> _consumer;
|
||||
|
||||
public DownloadHunterConsumerTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<DownloadHunterConsumer<SearchItem>>>();
|
||||
_downloadHunterMock = new Mock<IDownloadHunter>();
|
||||
_consumer = new DownloadHunterConsumer<SearchItem>(_loggerMock.Object, _downloadHunterMock.Object);
|
||||
}
|
||||
|
||||
#region Consume Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_CallsHuntDownloadsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateHuntRequest();
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_downloadHunterMock
|
||||
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_downloadHunterMock.Verify(h => h.HuntDownloadsAsync(request), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WhenHunterThrows_LogsErrorAndDoesNotRethrow()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateHuntRequest();
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_downloadHunterMock
|
||||
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
|
||||
.ThrowsAsync(new Exception("Hunt failed"));
|
||||
|
||||
// Act - Should not throw
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_loggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("failed to search for replacement")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_PassesCorrectRequestToHunter()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateHuntRequest();
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
DownloadHuntRequest<SearchItem>? capturedRequest = null;
|
||||
|
||||
_downloadHunterMock
|
||||
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
|
||||
.Callback<DownloadHuntRequest<SearchItem>>(r => capturedRequest = r)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal(request.InstanceType, capturedRequest.InstanceType);
|
||||
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_WithDifferentInstanceTypes_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new DownloadHuntRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Lidarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 999 },
|
||||
Record = CreateQueueRecord(),
|
||||
JobRunId = Guid.NewGuid()
|
||||
};
|
||||
var contextMock = CreateConsumeContextMock(request);
|
||||
|
||||
_downloadHunterMock
|
||||
.Setup(h => h.HuntDownloadsAsync(It.IsAny<DownloadHuntRequest<SearchItem>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _consumer.Consume(contextMock.Object);
|
||||
|
||||
// Assert
|
||||
_downloadHunterMock.Verify(h => h.HuntDownloadsAsync(
|
||||
It.Is<DownloadHuntRequest<SearchItem>>(r => r.InstanceType == InstanceType.Lidarr)), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static DownloadHuntRequest<SearchItem> CreateHuntRequest()
|
||||
{
|
||||
return new DownloadHuntRequest<SearchItem>
|
||||
{
|
||||
InstanceType = InstanceType.Radarr,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord(),
|
||||
JobRunId = Guid.NewGuid()
|
||||
};
|
||||
}
|
||||
|
||||
private static ArrInstance CreateArrInstance()
|
||||
{
|
||||
return new ArrInstance
|
||||
{
|
||||
Name = "Test Instance",
|
||||
Url = new Uri("http://radarr.local"),
|
||||
ApiKey = "test-api-key"
|
||||
};
|
||||
}
|
||||
|
||||
private static QueueRecord CreateQueueRecord()
|
||||
{
|
||||
return new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Record",
|
||||
Protocol = "torrent",
|
||||
DownloadId = "ABC123"
|
||||
};
|
||||
}
|
||||
|
||||
private static Mock<ConsumeContext<DownloadHuntRequest<SearchItem>>> CreateConsumeContextMock(DownloadHuntRequest<SearchItem> message)
|
||||
{
|
||||
var mock = new Mock<ConsumeContext<DownloadHuntRequest<SearchItem>>>();
|
||||
mock.Setup(c => c.Message).Returns(message);
|
||||
return mock;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadHunter;
|
||||
|
||||
public class DownloadHunterTests : IDisposable
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
|
||||
private readonly Mock<IArrClient> _arrClientMock;
|
||||
private readonly FakeTimeProvider _fakeTimeProvider;
|
||||
private readonly Infrastructure.Features.DownloadHunter.DownloadHunter _downloadHunter;
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
public DownloadHunterTests()
|
||||
{
|
||||
// Use SQLite in-memory with shared connection to support complex types
|
||||
_connection = new SqliteConnection("DataSource=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
_dataContext = new DataContext(options);
|
||||
_dataContext.Database.EnsureCreated();
|
||||
|
||||
_arrClientFactoryMock = new Mock<IArrClientFactory>();
|
||||
_arrClientMock = new Mock<IArrClient>();
|
||||
_fakeTimeProvider = new FakeTimeProvider();
|
||||
|
||||
_arrClientFactoryMock
|
||||
.Setup(f => f.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()))
|
||||
.Returns(_arrClientMock.Object);
|
||||
|
||||
_downloadHunter = new Infrastructure.Features.DownloadHunter.DownloadHunter(
|
||||
_dataContext,
|
||||
_arrClientFactoryMock.Object,
|
||||
_fakeTimeProvider
|
||||
);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dataContext.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
#region HuntDownloadsAsync - Search Disabled Tests
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenSearchDisabled_DoesNotCallArrClient()
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: false);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
await _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Assert
|
||||
_arrClientFactoryMock.Verify(f => f.GetClient(It.IsAny<InstanceType>(), It.IsAny<float>()), Times.Never);
|
||||
_arrClientMock.Verify(c => c.SearchItemsAsync(It.IsAny<ArrInstance>(), It.IsAny<HashSet<SearchItem>>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenSearchDisabled_ReturnsImmediately()
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: false);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Assert - Should complete without needing to advance time
|
||||
var completedTask = await Task.WhenAny(task, Task.Delay(100));
|
||||
Assert.Same(task, completedTask);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HuntDownloadsAsync - Search Enabled Tests
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsArrClientFactory()
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act - Start the task and advance time
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
|
||||
await task;
|
||||
|
||||
// Assert
|
||||
_arrClientFactoryMock.Verify(f => f.GetClient(request.InstanceType, It.IsAny<float>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenSearchEnabled_CallsSearchItemsAsync()
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
|
||||
await task;
|
||||
|
||||
// Assert
|
||||
_arrClientMock.Verify(
|
||||
c => c.SearchItemsAsync(
|
||||
request.Instance,
|
||||
It.Is<HashSet<SearchItem>>(s => s.Contains(request.SearchItem))),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstanceType.Sonarr)]
|
||||
[InlineData(InstanceType.Radarr)]
|
||||
[InlineData(InstanceType.Lidarr)]
|
||||
[InlineData(InstanceType.Readarr)]
|
||||
[InlineData(InstanceType.Whisparr)]
|
||||
public async Task HuntDownloadsAsync_UsesCorrectInstanceType(InstanceType instanceType)
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
|
||||
var request = CreateHuntRequest(instanceType);
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
|
||||
await task;
|
||||
|
||||
// Assert
|
||||
_arrClientFactoryMock.Verify(f => f.GetClient(instanceType, It.IsAny<float>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HuntDownloadsAsync - Delay Tests
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WaitsForConfiguredDelay()
|
||||
{
|
||||
// Arrange
|
||||
const ushort configuredDelay = 120;
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: configuredDelay);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Assert - Task should not complete before advancing time
|
||||
Assert.False(task.IsCompleted);
|
||||
|
||||
// Advance partial time - should still not complete
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(configuredDelay - 1));
|
||||
await Task.Delay(10); // Give the task a chance to complete if it would
|
||||
Assert.False(task.IsCompleted);
|
||||
|
||||
// Advance remaining time - should now complete
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await task;
|
||||
Assert.True(task.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenDelayBelowMinimum_UsesDefaultDelay()
|
||||
{
|
||||
// Arrange - Set delay below minimum (simulating manual DB edit)
|
||||
const ushort belowMinDelay = 10; // Below MinSearchDelaySeconds (60)
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: belowMinDelay);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Advance by the below-min value - should NOT complete because it should use default
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(belowMinDelay));
|
||||
await Task.Delay(10);
|
||||
Assert.False(task.IsCompleted);
|
||||
|
||||
// Advance to default delay - should now complete
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds - belowMinDelay));
|
||||
await task;
|
||||
Assert.True(task.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenDelayIsZero_UsesDefaultDelay()
|
||||
{
|
||||
// Arrange
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: 0);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Assert - Should not complete immediately
|
||||
Assert.False(task.IsCompleted);
|
||||
|
||||
// Advance to default delay
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.DefaultSearchDelaySeconds));
|
||||
await task;
|
||||
Assert.True(task.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenDelayAtMinimum_UsesConfiguredDelay()
|
||||
{
|
||||
// Arrange - Set delay exactly at minimum
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: Constants.MinSearchDelaySeconds);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Advance by minimum - should complete
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
|
||||
await task;
|
||||
Assert.True(task.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HuntDownloadsAsync_WhenDelayAboveMinimum_UsesConfiguredDelay()
|
||||
{
|
||||
// Arrange - Set delay above minimum
|
||||
const ushort aboveMinDelay = 180;
|
||||
await SetupGeneralConfig(searchEnabled: true, searchDelay: aboveMinDelay);
|
||||
var request = CreateHuntRequest();
|
||||
|
||||
// Act
|
||||
var task = _downloadHunter.HuntDownloadsAsync(request);
|
||||
|
||||
// Advance by minimum - should NOT complete yet
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(Constants.MinSearchDelaySeconds));
|
||||
await Task.Delay(10);
|
||||
Assert.False(task.IsCompleted);
|
||||
|
||||
// Advance remaining time
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromSeconds(aboveMinDelay - Constants.MinSearchDelaySeconds));
|
||||
await task;
|
||||
Assert.True(task.IsCompletedSuccessfully);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task SetupGeneralConfig(bool searchEnabled, ushort searchDelay = Constants.DefaultSearchDelaySeconds)
|
||||
{
|
||||
var generalConfig = new GeneralConfig
|
||||
{
|
||||
SearchEnabled = searchEnabled,
|
||||
SearchDelay = searchDelay
|
||||
};
|
||||
|
||||
_dataContext.GeneralConfigs.Add(generalConfig);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static DownloadHuntRequest<SearchItem> CreateHuntRequest(InstanceType instanceType = InstanceType.Sonarr)
|
||||
{
|
||||
return new DownloadHuntRequest<SearchItem>
|
||||
{
|
||||
InstanceType = instanceType,
|
||||
Instance = CreateArrInstance(),
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord(),
|
||||
JobRunId = Guid.NewGuid()
|
||||
};
|
||||
}
|
||||
|
||||
private static ArrInstance CreateArrInstance()
|
||||
{
|
||||
return new ArrInstance
|
||||
{
|
||||
Name = "Test Instance",
|
||||
Url = new Uri("http://arr.local"),
|
||||
ApiKey = "test-api-key",
|
||||
Version = 0
|
||||
};
|
||||
}
|
||||
|
||||
private static QueueRecord CreateQueueRecord()
|
||||
{
|
||||
return new QueueRecord
|
||||
{
|
||||
Id = 1,
|
||||
Title = "Test Record",
|
||||
Protocol = "torrent",
|
||||
DownloadId = "ABC123"
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -4,7 +4,6 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
@@ -15,8 +14,8 @@ using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -28,19 +27,18 @@ namespace Cleanuparr.Infrastructure.Tests.Features.DownloadRemover;
|
||||
public class QueueItemRemoverTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<QueueItemRemover>> _loggerMock;
|
||||
private readonly Mock<IBus> _busMock;
|
||||
private readonly MemoryCache _memoryCache;
|
||||
private readonly Mock<IArrClientFactory> _arrClientFactoryMock;
|
||||
private readonly Mock<IArrClient> _arrClientMock;
|
||||
private readonly EventPublisher _eventPublisher;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly QueueItemRemover _queueItemRemover;
|
||||
private readonly Guid _jobRunId;
|
||||
|
||||
public QueueItemRemoverTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<QueueItemRemover>>();
|
||||
_busMock = new Mock<IBus>();
|
||||
_memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
|
||||
_arrClientFactoryMock = new Mock<IArrClientFactory>();
|
||||
_arrClientMock = new Mock<IArrClient>();
|
||||
@@ -77,13 +75,16 @@ public class QueueItemRemoverTests : IDisposable
|
||||
Mock.Of<INotificationPublisher>(),
|
||||
dryRunInterceptorMock.Object);
|
||||
|
||||
// Create in-memory DataContext with seeded SeekerConfig
|
||||
_dataContext = TestDataContextFactory.Create();
|
||||
|
||||
_queueItemRemover = new QueueItemRemover(
|
||||
_loggerMock.Object,
|
||||
_busMock.Object,
|
||||
_memoryCache,
|
||||
_arrClientFactoryMock.Object,
|
||||
_eventPublisher,
|
||||
_eventsContext
|
||||
_eventsContext,
|
||||
_dataContext
|
||||
);
|
||||
|
||||
// Clear static RecurringHashes before each test
|
||||
@@ -94,6 +95,7 @@ public class QueueItemRemoverTests : IDisposable
|
||||
{
|
||||
_memoryCache.Dispose();
|
||||
_eventsContext.Dispose();
|
||||
_dataContext.Dispose();
|
||||
Striker.RecurringHashes.Clear();
|
||||
}
|
||||
|
||||
@@ -125,11 +127,10 @@ public class QueueItemRemoverTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_Success_PublishesDownloadHuntRequest()
|
||||
public async Task RemoveQueueItemAsync_Success_AddsSearchQueueItem()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
DownloadHuntRequest<SearchItem>? capturedRequest = null;
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
@@ -139,23 +140,15 @@ public class QueueItemRemoverTests : IDisposable
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
_busMock
|
||||
.Setup(b => b.Publish(It.IsAny<DownloadHuntRequest<SearchItem>>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<DownloadHuntRequest<SearchItem>, CancellationToken>((r, _) => capturedRequest = r)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_busMock.Verify(b => b.Publish(
|
||||
It.IsAny<DownloadHuntRequest<SearchItem>>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal(request.InstanceType, capturedRequest!.InstanceType);
|
||||
Assert.Equal(request.Instance, capturedRequest.Instance);
|
||||
Assert.Equal(request.SearchItem.Id, capturedRequest.SearchItem.Id);
|
||||
var queueItems = await _dataContext.SearchQueue.ToListAsync();
|
||||
Assert.Single(queueItems);
|
||||
Assert.Equal(request.Instance.Id, queueItems[0].ArrInstanceId);
|
||||
Assert.Equal(request.SearchItem.Id, queueItems[0].ItemId);
|
||||
Assert.Equal(request.Record.Title, queueItems[0].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -212,7 +205,7 @@ public class QueueItemRemoverTests : IDisposable
|
||||
#region RemoveQueueItemAsync - Recurring Hash Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_DoesNotPublishHuntRequest()
|
||||
public async Task RemoveQueueItemAsync_WhenHashIsRecurring_DoesNotAddSearchQueueItem()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
@@ -231,9 +224,8 @@ public class QueueItemRemoverTests : IDisposable
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_busMock.Verify(b => b.Publish(
|
||||
It.IsAny<DownloadHuntRequest<SearchItem>>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
var queueItems = await _dataContext.SearchQueue.ToListAsync();
|
||||
Assert.Empty(queueItems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -260,7 +252,7 @@ public class QueueItemRemoverTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenHashIsNotRecurring_PublishesHuntRequest()
|
||||
public async Task RemoveQueueItemAsync_WhenHashIsNotRecurring_AddsSearchQueueItem()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest();
|
||||
@@ -277,9 +269,8 @@ public class QueueItemRemoverTests : IDisposable
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_busMock.Verify(b => b.Publish(
|
||||
It.IsAny<DownloadHuntRequest<SearchItem>>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
var queueItems = await _dataContext.SearchQueue.ToListAsync();
|
||||
Assert.Single(queueItems);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -287,7 +278,7 @@ public class QueueItemRemoverTests : IDisposable
|
||||
#region RemoveQueueItemAsync - SkipSearch Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenSkipSearch_DoesNotPublishHuntRequest()
|
||||
public async Task RemoveQueueItemAsync_WhenSkipSearch_DoesNotAddSearchQueueItem()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateRemoveRequest(skipSearch: true);
|
||||
@@ -304,9 +295,8 @@ public class QueueItemRemoverTests : IDisposable
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
_busMock.Verify(b => b.Publish(
|
||||
It.IsAny<DownloadHuntRequest<SearchItem>>(),
|
||||
It.IsAny<CancellationToken>()), Times.Never);
|
||||
var queueItems = await _dataContext.SearchQueue.ToListAsync();
|
||||
Assert.Empty(queueItems);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -333,6 +323,36 @@ public class QueueItemRemoverTests : IDisposable
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveQueueItemAsync - SearchEnabled Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveQueueItemAsync_WhenSearchDisabled_DoesNotAddSearchQueueItem()
|
||||
{
|
||||
// Arrange
|
||||
var seekerConfig = await _dataContext.SeekerConfigs.FirstAsync();
|
||||
seekerConfig.SearchEnabled = false;
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
var request = CreateRemoveRequest();
|
||||
|
||||
_arrClientMock
|
||||
.Setup(c => c.DeleteQueueItemAsync(
|
||||
It.IsAny<ArrInstance>(),
|
||||
It.IsAny<QueueRecord>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<DeleteReason>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _queueItemRemover.RemoveQueueItemAsync(request);
|
||||
|
||||
// Assert
|
||||
var queueItems = await _dataContext.SearchQueue.ToListAsync();
|
||||
Assert.Empty(queueItems);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RemoveQueueItemAsync - HTTP Error Tests
|
||||
|
||||
[Fact]
|
||||
@@ -496,10 +516,13 @@ public class QueueItemRemoverTests : IDisposable
|
||||
DeleteReason deleteReason = DeleteReason.Stalled,
|
||||
bool skipSearch = false)
|
||||
{
|
||||
// Use an ArrInstance that exists in the DB to satisfy FK constraint on SearchQueueItem
|
||||
var instance = GetOrCreateArrInstance(instanceType);
|
||||
|
||||
return new QueueItemRemoveRequest<SearchItem>
|
||||
{
|
||||
InstanceType = instanceType,
|
||||
Instance = CreateArrInstance(),
|
||||
Instance = instance,
|
||||
SearchItem = new SearchItem { Id = 123 },
|
||||
Record = CreateQueueRecord(),
|
||||
RemoveFromClient = removeFromClient,
|
||||
@@ -509,13 +532,16 @@ public class QueueItemRemoverTests : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
private static ArrInstance CreateArrInstance()
|
||||
private ArrInstance GetOrCreateArrInstance(InstanceType instanceType)
|
||||
{
|
||||
return new ArrInstance
|
||||
return instanceType switch
|
||||
{
|
||||
Name = "Test Instance",
|
||||
Url = new Uri("http://arr.local"),
|
||||
ApiKey = "test-api-key"
|
||||
InstanceType.Sonarr => TestDataContextFactory.AddSonarrInstance(_dataContext),
|
||||
InstanceType.Radarr => TestDataContextFactory.AddRadarrInstance(_dataContext),
|
||||
InstanceType.Lidarr => TestDataContextFactory.AddLidarrInstance(_dataContext),
|
||||
InstanceType.Readarr => TestDataContextFactory.AddReadarrInstance(_dataContext),
|
||||
InstanceType.Whisparr => TestDataContextFactory.AddWhisparrInstance(_dataContext),
|
||||
_ => TestDataContextFactory.AddSonarrInstance(_dataContext),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,868 @@
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using CustomFormatScoreSyncer = Cleanuparr.Infrastructure.Features.Jobs.CustomFormatScoreSyncer;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Jobs;
|
||||
|
||||
[Collection(JobHandlerCollection.Name)]
|
||||
public class CustomFormatScoreSyncerTests : IDisposable
|
||||
{
|
||||
private readonly JobHandlerFixture _fixture;
|
||||
private readonly Mock<ILogger<CustomFormatScoreSyncer>> _logger;
|
||||
private readonly Mock<IRadarrClient> _radarrClient;
|
||||
private readonly Mock<ISonarrClient> _sonarrClient;
|
||||
private readonly Mock<IHubContext<AppHub>> _hubContext;
|
||||
|
||||
public CustomFormatScoreSyncerTests(JobHandlerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_fixture.RecreateDataContext();
|
||||
_fixture.ResetMocks();
|
||||
_logger = new Mock<ILogger<CustomFormatScoreSyncer>>();
|
||||
_radarrClient = new Mock<IRadarrClient>();
|
||||
_sonarrClient = new Mock<ISonarrClient>();
|
||||
_hubContext = new Mock<IHubContext<AppHub>>();
|
||||
|
||||
var mockClients = new Mock<IHubClients>();
|
||||
var mockClientProxy = new Mock<IClientProxy>();
|
||||
mockClients.Setup(c => c.All).Returns(mockClientProxy.Object);
|
||||
_hubContext.Setup(h => h.Clients).Returns(mockClients.Object);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private CustomFormatScoreSyncer CreateSut()
|
||||
{
|
||||
return new CustomFormatScoreSyncer(
|
||||
_logger.Object,
|
||||
_fixture.DataContext,
|
||||
_radarrClient.Object,
|
||||
_sonarrClient.Object,
|
||||
_fixture.TimeProvider,
|
||||
_hubContext.Object
|
||||
);
|
||||
}
|
||||
|
||||
#region ExecuteAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenCustomFormatScoreDisabled_ReturnsEarly()
|
||||
{
|
||||
// Arrange — UseCustomFormatScore is false by default in seed data
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = false;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — no API calls made
|
||||
_radarrClient.Verify(
|
||||
x => x.GetAllMoviesAsync(It.IsAny<ArrInstance>()),
|
||||
Times.Never);
|
||||
_sonarrClient.Verify(
|
||||
x => x.GetAllSeriesAsync(It.IsAny<ArrInstance>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenNoEnabledInstances_ReturnsEarly()
|
||||
{
|
||||
// Arrange — enable CF scoring but add no SeekerInstanceConfigs
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — no API calls
|
||||
_radarrClient.Verify(
|
||||
x => x.GetAllMoviesAsync(It.IsAny<ArrInstance>()),
|
||||
Times.Never);
|
||||
_sonarrClient.Verify(
|
||||
x => x.GetAllSeriesAsync(It.IsAny<ArrInstance>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_SyncsRadarrMovieScores()
|
||||
{
|
||||
// Arrange
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
// Mock quality profiles
|
||||
_radarrClient
|
||||
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
|
||||
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
|
||||
|
||||
// Mock movies with files
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
|
||||
.ReturnsAsync([
|
||||
new SearchableMovie
|
||||
{
|
||||
Id = 10,
|
||||
Title = "Test Movie",
|
||||
HasFile = true,
|
||||
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
|
||||
QualityProfileId = 1,
|
||||
Status = "released",
|
||||
Monitored = true
|
||||
}
|
||||
]);
|
||||
|
||||
// Mock file scores
|
||||
_radarrClient
|
||||
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is<List<long>>(ids => ids.Contains(100))))
|
||||
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — CF score entry was saved
|
||||
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
|
||||
Assert.Single(entries);
|
||||
|
||||
var entry = entries[0];
|
||||
Assert.Equal(radarrInstance.Id, entry.ArrInstanceId);
|
||||
Assert.Equal(10, entry.ExternalItemId);
|
||||
Assert.Equal(250, entry.CurrentScore);
|
||||
Assert.Equal(500, entry.CutoffScore);
|
||||
Assert.Equal("HD", entry.QualityProfileName);
|
||||
Assert.Equal(InstanceType.Radarr, entry.ItemType);
|
||||
Assert.True(entry.IsMonitored);
|
||||
|
||||
// Initial history entry should also be created
|
||||
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
|
||||
Assert.Single(history);
|
||||
Assert.Equal(250, history[0].Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RecordsHistoryOnScoreChange()
|
||||
{
|
||||
// Arrange
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
// Pre-existing CF score entry with a different score
|
||||
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 10,
|
||||
EpisodeId = 0,
|
||||
ItemType = InstanceType.Radarr,
|
||||
Title = "Test Movie",
|
||||
FileId = 100,
|
||||
CurrentScore = 200,
|
||||
CutoffScore = 500,
|
||||
QualityProfileName = "HD",
|
||||
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
// Mock quality profiles
|
||||
_radarrClient
|
||||
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
|
||||
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
|
||||
|
||||
// Mock movies — same movie but score changed from 200 to 350
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
|
||||
.ReturnsAsync([
|
||||
new SearchableMovie
|
||||
{
|
||||
Id = 10,
|
||||
Title = "Test Movie",
|
||||
HasFile = true,
|
||||
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
|
||||
QualityProfileId = 1,
|
||||
Status = "released",
|
||||
Monitored = true
|
||||
}
|
||||
]);
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is<List<long>>(ids => ids.Contains(100))))
|
||||
.ReturnsAsync(new Dictionary<long, int> { { 100, 350 } });
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — existing entry should be updated
|
||||
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
|
||||
Assert.Single(entries);
|
||||
Assert.Equal(350, entries[0].CurrentScore);
|
||||
|
||||
// History entry should be created because score changed (200 -> 350)
|
||||
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
|
||||
Assert.Single(history);
|
||||
Assert.Equal(350, history[0].Score);
|
||||
Assert.Equal(InstanceType.Radarr, history[0].ItemType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_TracksUnmonitoredMovie()
|
||||
{
|
||||
// Arrange
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
|
||||
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
|
||||
.ReturnsAsync([
|
||||
new SearchableMovie
|
||||
{
|
||||
Id = 10,
|
||||
Title = "Unmonitored Movie",
|
||||
HasFile = true,
|
||||
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
|
||||
QualityProfileId = 1,
|
||||
Status = "released",
|
||||
Monitored = false
|
||||
}
|
||||
]);
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is<List<long>>(ids => ids.Contains(100))))
|
||||
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — entry should be saved with IsMonitored = false
|
||||
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
|
||||
Assert.Single(entries);
|
||||
Assert.False(entries[0].IsMonitored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_UpdatesMonitoredStatusOnSync()
|
||||
{
|
||||
// Arrange
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
// Pre-existing entry that was monitored
|
||||
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 10,
|
||||
EpisodeId = 0,
|
||||
ItemType = InstanceType.Radarr,
|
||||
Title = "Test Movie",
|
||||
FileId = 100,
|
||||
CurrentScore = 250,
|
||||
CutoffScore = 500,
|
||||
QualityProfileName = "HD",
|
||||
IsMonitored = true,
|
||||
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
|
||||
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
|
||||
|
||||
// Movie is now unmonitored
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
|
||||
.ReturnsAsync([
|
||||
new SearchableMovie
|
||||
{
|
||||
Id = 10, Title = "Test Movie", HasFile = true,
|
||||
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
|
||||
QualityProfileId = 1, Status = "released", Monitored = false
|
||||
}
|
||||
]);
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.IsAny<List<long>>()))
|
||||
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — IsMonitored should be updated to false
|
||||
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
|
||||
Assert.Single(entries);
|
||||
Assert.False(entries[0].IsMonitored);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Sonarr Sync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_SyncsSonarrEpisodeScores()
|
||||
{
|
||||
// Arrange
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = sonarrInstance.Id,
|
||||
ArrInstance = sonarrInstance,
|
||||
Enabled = true
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
// Mock quality profiles
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetQualityProfilesAsync(sonarrInstance))
|
||||
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
|
||||
|
||||
// Mock series
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetAllSeriesAsync(sonarrInstance))
|
||||
.ReturnsAsync([
|
||||
new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true }
|
||||
]);
|
||||
|
||||
// Mock episodes — one with a file, one without
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetEpisodesAsync(sonarrInstance, 10))
|
||||
.ReturnsAsync([
|
||||
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, EpisodeFileId = 500, HasFile = true, Monitored = true },
|
||||
new SearchableEpisode { Id = 101, SeasonNumber = 1, EpisodeNumber = 2, EpisodeFileId = 0, HasFile = false }
|
||||
]);
|
||||
|
||||
// Mock episode files with CF scores
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetEpisodeFilesAsync(sonarrInstance, 10))
|
||||
.ReturnsAsync([
|
||||
new ArrEpisodeFile { Id = 500, CustomFormatScore = 300, QualityCutoffNotMet = false }
|
||||
]);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — only the episode with a file should have an entry
|
||||
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
|
||||
Assert.Single(entries);
|
||||
|
||||
var entry = entries[0];
|
||||
Assert.Equal(sonarrInstance.Id, entry.ArrInstanceId);
|
||||
Assert.Equal(10, entry.ExternalItemId);
|
||||
Assert.Equal(100, entry.EpisodeId);
|
||||
Assert.Equal(300, entry.CurrentScore);
|
||||
Assert.Equal(500, entry.CutoffScore);
|
||||
Assert.Equal(InstanceType.Sonarr, entry.ItemType);
|
||||
Assert.True(entry.IsMonitored);
|
||||
Assert.Contains("S01E01", entry.Title);
|
||||
|
||||
// Initial history should be created
|
||||
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
|
||||
Assert.Single(history);
|
||||
Assert.Equal(300, history[0].Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_SonarrSync_SkipsEpisodesWithoutFiles()
|
||||
{
|
||||
// Arrange
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = sonarrInstance.Id,
|
||||
ArrInstance = sonarrInstance,
|
||||
Enabled = true
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetQualityProfilesAsync(sonarrInstance))
|
||||
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
|
||||
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetAllSeriesAsync(sonarrInstance))
|
||||
.ReturnsAsync([
|
||||
new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true }
|
||||
]);
|
||||
|
||||
// All episodes have EpisodeFileId = 0 (no file)
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetEpisodesAsync(sonarrInstance, 10))
|
||||
.ReturnsAsync([
|
||||
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, EpisodeFileId = 0, HasFile = false }
|
||||
]);
|
||||
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetEpisodeFilesAsync(sonarrInstance, 10))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — no entries created
|
||||
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
|
||||
Assert.Empty(entries);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Score Unchanged Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ScoreUnchanged_DoesNotRecordHistory()
|
||||
{
|
||||
// Arrange
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
// Pre-existing entry with score = 250 (same as what will be returned)
|
||||
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 10,
|
||||
EpisodeId = 0,
|
||||
ItemType = InstanceType.Radarr,
|
||||
Title = "Test Movie",
|
||||
FileId = 100,
|
||||
CurrentScore = 250,
|
||||
CutoffScore = 500,
|
||||
QualityProfileName = "HD",
|
||||
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
|
||||
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
|
||||
.ReturnsAsync([
|
||||
new SearchableMovie
|
||||
{
|
||||
Id = 10, Title = "Test Movie", HasFile = true,
|
||||
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
|
||||
QualityProfileId = 1, Status = "released", Monitored = true
|
||||
}
|
||||
]);
|
||||
|
||||
// Score unchanged: still 250
|
||||
_radarrClient
|
||||
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.Is<List<long>>(ids => ids.Contains(100))))
|
||||
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — no history entries (score didn't change)
|
||||
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
|
||||
Assert.Empty(history);
|
||||
|
||||
// Entry should still be updated (LastSyncedAt changes)
|
||||
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
|
||||
Assert.Single(entries);
|
||||
Assert.Equal(250, entries[0].CurrentScore);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Stale Entry Cleanup Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_CleansUpEntriesForRemovedMovies()
|
||||
{
|
||||
// Arrange
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
// Pre-existing entry for a movie that no longer exists in library
|
||||
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 999,
|
||||
EpisodeId = 0,
|
||||
ItemType = InstanceType.Radarr,
|
||||
Title = "Deleted Movie",
|
||||
FileId = 999,
|
||||
CurrentScore = 100,
|
||||
CutoffScore = 500,
|
||||
QualityProfileName = "HD",
|
||||
LastSyncedAt = new DateTime(1999, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
|
||||
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
|
||||
|
||||
// Library now only has movie 10 (not 999)
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
|
||||
.ReturnsAsync([
|
||||
new SearchableMovie
|
||||
{
|
||||
Id = 10, Title = "Current Movie", HasFile = true,
|
||||
MovieFile = new MovieFileInfo { Id = 100, QualityCutoffNotMet = false },
|
||||
QualityProfileId = 1, Status = "released", Monitored = true
|
||||
}
|
||||
]);
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.IsAny<List<long>>()))
|
||||
.ReturnsAsync(new Dictionary<long, int> { { 100, 250 } });
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — entry for removed movie 999 should be deleted
|
||||
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
|
||||
Assert.Single(entries);
|
||||
Assert.Equal(10, entries[0].ExternalItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_PreservesEntryWhenMovieExistsButHasNoFile()
|
||||
{
|
||||
// Arrange — simulates an RSS upgrade where the old file was removed
|
||||
// but the new file hasn't been imported yet
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
// Pre-existing entry with score history
|
||||
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 10,
|
||||
EpisodeId = 0,
|
||||
ItemType = InstanceType.Radarr,
|
||||
Title = "Mario Bros",
|
||||
FileId = 100,
|
||||
CurrentScore = 250,
|
||||
CutoffScore = 500,
|
||||
QualityProfileName = "HD",
|
||||
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
|
||||
});
|
||||
_fixture.DataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 10,
|
||||
EpisodeId = 0,
|
||||
ItemType = InstanceType.Radarr,
|
||||
Title = "Mario Bros",
|
||||
Score = 250,
|
||||
CutoffScore = 500,
|
||||
RecordedAt = DateTime.UtcNow.AddHours(-1)
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
|
||||
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
|
||||
|
||||
// Movie still exists in Radarr but HasFile is false (RSS upgrade in progress)
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
|
||||
.ReturnsAsync([
|
||||
new SearchableMovie
|
||||
{
|
||||
Id = 10, Title = "Mario Bros", HasFile = false,
|
||||
MovieFile = null,
|
||||
QualityProfileId = 1, Status = "released", Monitored = true
|
||||
}
|
||||
]);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — entry and history should be preserved since the movie still exists
|
||||
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
|
||||
Assert.Single(entries);
|
||||
Assert.Equal(10, entries[0].ExternalItemId);
|
||||
Assert.Equal(250, entries[0].CurrentScore);
|
||||
|
||||
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
|
||||
Assert.Single(history);
|
||||
Assert.Equal(250, history[0].Score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_PreservesEntryWhenMovieFileScoreNotReturned()
|
||||
{
|
||||
// Arrange — simulates a newly imported file that doesn't have CF scores calculated yet
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
// Pre-existing entry with history
|
||||
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 10,
|
||||
EpisodeId = 0,
|
||||
ItemType = InstanceType.Radarr,
|
||||
Title = "Mario Bros",
|
||||
FileId = 100,
|
||||
CurrentScore = 250,
|
||||
CutoffScore = 500,
|
||||
QualityProfileName = "HD",
|
||||
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
|
||||
});
|
||||
_fixture.DataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ExternalItemId = 10,
|
||||
EpisodeId = 0,
|
||||
ItemType = InstanceType.Radarr,
|
||||
Title = "Mario Bros",
|
||||
Score = 250,
|
||||
CutoffScore = 500,
|
||||
RecordedAt = DateTime.UtcNow.AddHours(-1)
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
_radarrClient
|
||||
.Setup(x => x.GetQualityProfilesAsync(radarrInstance))
|
||||
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
|
||||
|
||||
// Movie has a new file (different FileId) after RSS upgrade
|
||||
_radarrClient
|
||||
.Setup(x => x.GetAllMoviesAsync(radarrInstance))
|
||||
.ReturnsAsync([
|
||||
new SearchableMovie
|
||||
{
|
||||
Id = 10, Title = "Mario Bros", HasFile = true,
|
||||
MovieFile = new MovieFileInfo { Id = 200, QualityCutoffNotMet = false },
|
||||
QualityProfileId = 1, Status = "released", Monitored = true
|
||||
}
|
||||
]);
|
||||
|
||||
// New file returns no score (not yet calculated by Radarr)
|
||||
_radarrClient
|
||||
.Setup(x => x.GetMovieFileScoresAsync(radarrInstance, It.IsAny<List<long>>()))
|
||||
.ReturnsAsync(new Dictionary<long, int>());
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — entry and history should be preserved since the movie still exists
|
||||
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
|
||||
Assert.Single(entries);
|
||||
Assert.Equal(10, entries[0].ExternalItemId);
|
||||
Assert.Equal(250, entries[0].CurrentScore);
|
||||
|
||||
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
|
||||
Assert.Single(history);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Sonarr_PreservesEntryWhenEpisodeTemporarilyWithoutFile()
|
||||
{
|
||||
// Arrange — simulates a Sonarr episode whose file was replaced via RSS
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.UseCustomFormatScore = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = sonarrInstance.Id,
|
||||
ArrInstance = sonarrInstance,
|
||||
Enabled = true
|
||||
});
|
||||
|
||||
// Pre-existing CF score entry for an episode
|
||||
_fixture.DataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = sonarrInstance.Id,
|
||||
ExternalItemId = 10,
|
||||
EpisodeId = 100,
|
||||
ItemType = InstanceType.Sonarr,
|
||||
Title = "Test Series S01E01",
|
||||
FileId = 500,
|
||||
CurrentScore = 300,
|
||||
CutoffScore = 500,
|
||||
QualityProfileName = "HD",
|
||||
LastSyncedAt = DateTime.UtcNow.AddHours(-1)
|
||||
});
|
||||
_fixture.DataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
|
||||
{
|
||||
ArrInstanceId = sonarrInstance.Id,
|
||||
ExternalItemId = 10,
|
||||
EpisodeId = 100,
|
||||
ItemType = InstanceType.Sonarr,
|
||||
Title = "Test Series S01E01",
|
||||
Score = 300,
|
||||
CutoffScore = 500,
|
||||
RecordedAt = DateTime.UtcNow.AddHours(-1)
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetQualityProfilesAsync(sonarrInstance))
|
||||
.ReturnsAsync([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]);
|
||||
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetAllSeriesAsync(sonarrInstance))
|
||||
.ReturnsAsync([
|
||||
new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true }
|
||||
]);
|
||||
|
||||
// Episode exists but has no file currently (RSS upgrade in progress)
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetEpisodesAsync(sonarrInstance, 10))
|
||||
.ReturnsAsync([
|
||||
new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, EpisodeFileId = 0, HasFile = false, Monitored = true }
|
||||
]);
|
||||
|
||||
_sonarrClient
|
||||
.Setup(x => x.GetEpisodeFilesAsync(sonarrInstance, 10))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — entry and history should be preserved
|
||||
var entries = await _fixture.DataContext.CustomFormatScoreEntries.ToListAsync();
|
||||
Assert.Single(entries);
|
||||
Assert.Equal(10, entries[0].ExternalItemId);
|
||||
Assert.Equal(100, entries[0].EpisodeId);
|
||||
Assert.Equal(300, entries[0].CurrentScore);
|
||||
|
||||
var history = await _fixture.DataContext.CustomFormatScoreHistory.ToListAsync();
|
||||
Assert.Single(history);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -94,6 +95,14 @@ public static class TestDataContextFactory
|
||||
UnlinkedCategories = []
|
||||
});
|
||||
|
||||
// Seeker config
|
||||
context.SeekerConfigs.Add(new SeekerConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
SearchEnabled = true,
|
||||
ProactiveSearchEnabled = false
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Seeker;
|
||||
using Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Tests.Features.Seeker;
|
||||
|
||||
public sealed class ItemSelectorTests
|
||||
{
|
||||
private static readonly List<(long Id, DateTime? Added, DateTime? LastSearched)> SampleCandidates =
|
||||
[
|
||||
(1, new DateTime(2024, 1, 1), new DateTime(2024, 6, 1)),
|
||||
(2, new DateTime(2024, 3, 1), new DateTime(2024, 5, 1)),
|
||||
(3, new DateTime(2024, 5, 1), null),
|
||||
(4, new DateTime(2024, 2, 1), new DateTime(2024, 7, 1)),
|
||||
(5, null, new DateTime(2024, 4, 1)),
|
||||
];
|
||||
|
||||
#region ItemSelectorFactory Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(SelectionStrategy.OldestSearchFirst, typeof(OldestSearchFirstSelector))]
|
||||
[InlineData(SelectionStrategy.OldestSearchWeighted, typeof(OldestSearchWeightedSelector))]
|
||||
[InlineData(SelectionStrategy.NewestFirst, typeof(NewestFirstSelector))]
|
||||
[InlineData(SelectionStrategy.NewestWeighted, typeof(NewestWeightedSelector))]
|
||||
[InlineData(SelectionStrategy.BalancedWeighted, typeof(BalancedWeightedSelector))]
|
||||
[InlineData(SelectionStrategy.Random, typeof(RandomSelector))]
|
||||
public void Factory_Create_ReturnsCorrectSelectorType(SelectionStrategy strategy, Type expectedType)
|
||||
{
|
||||
var selector = ItemSelectorFactory.Create(strategy);
|
||||
|
||||
Assert.IsType(expectedType, selector);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_Create_InvalidStrategy_ThrowsArgumentOutOfRangeException()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => ItemSelectorFactory.Create((SelectionStrategy)999));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NewestFirstSelector Tests
|
||||
|
||||
[Fact]
|
||||
public void NewestFirst_Select_OrdersByAddedDescending()
|
||||
{
|
||||
var selector = new NewestFirstSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
// Newest added: 3 (May), 2 (Mar), 4 (Feb)
|
||||
Assert.Equal([3, 2, 4], result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestFirst_Select_NullAddedDates_TreatedAsOldest()
|
||||
{
|
||||
var selector = new NewestFirstSelector();
|
||||
|
||||
// Select all — item 5 (null Added) should be last
|
||||
var result = selector.Select(SampleCandidates, 5);
|
||||
|
||||
Assert.Equal(5, result.Last());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestFirst_Select_ReturnsRequestedCount()
|
||||
{
|
||||
var selector = new NewestFirstSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 2);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestFirst_Select_EmptyInput_ReturnsEmptyList()
|
||||
{
|
||||
var selector = new NewestFirstSelector();
|
||||
|
||||
var result = selector.Select([], 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestFirst_Select_CountExceedsCandidates_ReturnsAll()
|
||||
{
|
||||
var selector = new NewestFirstSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 100);
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region OldestSearchFirstSelector Tests
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchFirst_Select_OrdersByLastSearchedAscending()
|
||||
{
|
||||
var selector = new OldestSearchFirstSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
// Never searched first (null → MinValue), then oldest: 3 (null), 5 (Apr), 2 (May)
|
||||
Assert.Equal([3, 5, 2], result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchFirst_Select_NullLastSearched_PrioritizedFirst()
|
||||
{
|
||||
var selector = new OldestSearchFirstSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 1);
|
||||
|
||||
// Item 3 has LastSearched = null, should be first
|
||||
Assert.Equal(3, result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchFirst_Select_ReturnsRequestedCount()
|
||||
{
|
||||
var selector = new OldestSearchFirstSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 2);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchFirst_Select_EmptyInput_ReturnsEmptyList()
|
||||
{
|
||||
var selector = new OldestSearchFirstSelector();
|
||||
|
||||
var result = selector.Select([], 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RandomSelector Tests
|
||||
|
||||
[Fact]
|
||||
public void Random_Select_ReturnsRequestedCount()
|
||||
{
|
||||
var selector = new RandomSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Random_Select_CountExceedsCandidates_ReturnsAll()
|
||||
{
|
||||
var selector = new RandomSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 100);
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Random_Select_EmptyInput_ReturnsEmptyList()
|
||||
{
|
||||
var selector = new RandomSelector();
|
||||
|
||||
var result = selector.Select([], 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Random_Select_NoDuplicateIds()
|
||||
{
|
||||
var selector = new RandomSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 5);
|
||||
|
||||
Assert.Equal(result.Count, result.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Random_Select_ResultsAreSubsetOfInput()
|
||||
{
|
||||
var selector = new RandomSelector();
|
||||
var inputIds = SampleCandidates.Select(c => c.Id).ToHashSet();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
Assert.All(result, id => Assert.Contains(id, inputIds));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NewestWeightedSelector Tests
|
||||
|
||||
[Fact]
|
||||
public void NewestWeighted_Select_ReturnsRequestedCount()
|
||||
{
|
||||
var selector = new NewestWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestWeighted_Select_EmptyInput_ReturnsEmptyList()
|
||||
{
|
||||
var selector = new NewestWeightedSelector();
|
||||
|
||||
var result = selector.Select([], 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestWeighted_Select_CountExceedsCandidates_ReturnsAll()
|
||||
{
|
||||
var selector = new NewestWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 100);
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestWeighted_Select_NoDuplicateIds()
|
||||
{
|
||||
var selector = new NewestWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 5);
|
||||
|
||||
Assert.Equal(result.Count, result.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewestWeighted_Select_SingleCandidate_ReturnsThatCandidate()
|
||||
{
|
||||
var selector = new NewestWeightedSelector();
|
||||
List<(long Id, DateTime? Added, DateTime? LastSearched)> single = [(42, DateTime.UtcNow, null)];
|
||||
|
||||
var result = selector.Select(single, 1);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(42, result[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region OldestSearchWeightedSelector Tests
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchWeighted_Select_ReturnsRequestedCount()
|
||||
{
|
||||
var selector = new OldestSearchWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchWeighted_Select_EmptyInput_ReturnsEmptyList()
|
||||
{
|
||||
var selector = new OldestSearchWeightedSelector();
|
||||
|
||||
var result = selector.Select([], 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchWeighted_Select_CountExceedsCandidates_ReturnsAll()
|
||||
{
|
||||
var selector = new OldestSearchWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 100);
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchWeighted_Select_NoDuplicateIds()
|
||||
{
|
||||
var selector = new OldestSearchWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 5);
|
||||
|
||||
Assert.Equal(result.Count, result.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestSearchWeighted_Select_SingleCandidate_ReturnsThatCandidate()
|
||||
{
|
||||
var selector = new OldestSearchWeightedSelector();
|
||||
List<(long Id, DateTime? Added, DateTime? LastSearched)> single = [(42, DateTime.UtcNow, null)];
|
||||
|
||||
var result = selector.Select(single, 1);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(42, result[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BalancedWeightedSelector Tests
|
||||
|
||||
[Fact]
|
||||
public void BalancedWeighted_Select_ReturnsRequestedCount()
|
||||
{
|
||||
var selector = new BalancedWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BalancedWeighted_Select_EmptyInput_ReturnsEmptyList()
|
||||
{
|
||||
var selector = new BalancedWeightedSelector();
|
||||
|
||||
var result = selector.Select([], 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BalancedWeighted_Select_CountExceedsCandidates_ReturnsAll()
|
||||
{
|
||||
var selector = new BalancedWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 100);
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BalancedWeighted_Select_NoDuplicateIds()
|
||||
{
|
||||
var selector = new BalancedWeightedSelector();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 5);
|
||||
|
||||
Assert.Equal(result.Count, result.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BalancedWeighted_Select_SingleCandidate_ReturnsThatCandidate()
|
||||
{
|
||||
var selector = new BalancedWeightedSelector();
|
||||
List<(long Id, DateTime? Added, DateTime? LastSearched)> single = [(42, DateTime.UtcNow, null)];
|
||||
|
||||
var result = selector.Select(single, 1);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(42, result[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BalancedWeighted_Select_ResultsAreSubsetOfInput()
|
||||
{
|
||||
var selector = new BalancedWeightedSelector();
|
||||
var inputIds = SampleCandidates.Select(c => c.Id).ToHashSet();
|
||||
|
||||
var result = selector.Select(SampleCandidates, 3);
|
||||
|
||||
Assert.All(result, id => Assert.Contains(id, inputIds));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region WeightedRandomByRank Tests
|
||||
|
||||
[Fact]
|
||||
public void WeightedRandomByRank_ReturnsRequestedCount()
|
||||
{
|
||||
var ranked = SampleCandidates.OrderBy(c => c.LastSearched ?? DateTime.MinValue).ToList();
|
||||
|
||||
var result = OldestSearchWeightedSelector.WeightedRandomByRank(ranked, 3);
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeightedRandomByRank_CountExceedsCandidates_ReturnsAll()
|
||||
{
|
||||
var ranked = SampleCandidates.OrderBy(c => c.LastSearched ?? DateTime.MinValue).ToList();
|
||||
|
||||
var result = OldestSearchWeightedSelector.WeightedRandomByRank(ranked, 100);
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeightedRandomByRank_NoDuplicateIds()
|
||||
{
|
||||
var ranked = SampleCandidates.OrderBy(c => c.LastSearched ?? DateTime.MinValue).ToList();
|
||||
|
||||
var result = OldestSearchWeightedSelector.WeightedRandomByRank(ranked, 5);
|
||||
|
||||
Assert.Equal(result.Count, result.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeightedRandomByRank_EmptyInput_ReturnsEmptyList()
|
||||
{
|
||||
var result = OldestSearchWeightedSelector.WeightedRandomByRank([], 5);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -227,6 +227,86 @@ public class EventPublisher : IEventPublisher
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a search triggered event with context data and notifications.
|
||||
/// Returns the event ID so the SeekerCommandMonitor can update it on completion.
|
||||
/// </summary>
|
||||
public async Task<Guid> PublishSearchTriggered(string instanceName, int itemCount, IEnumerable<string> items, SeekerSearchType searchType, Guid? cycleId = null)
|
||||
{
|
||||
var itemList = items as string[] ?? items.ToArray();
|
||||
var itemsDisplay = string.Join(", ", itemList.Take(5)) + (itemList.Length > 5 ? $" (+{itemList.Length - 5} more)" : "");
|
||||
|
||||
AppEvent eventEntity = new()
|
||||
{
|
||||
EventType = EventType.SearchTriggered,
|
||||
Message = $"Searched {itemCount} items on {instanceName}: {itemsDisplay}",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = JsonSerializer.Serialize(
|
||||
new { InstanceName = instanceName, ItemCount = itemCount, Items = itemList, SearchType = searchType.ToString(), CycleId = cycleId },
|
||||
new JsonSerializerOptions { Converters = { new JsonStringEnumConverter() } }),
|
||||
SearchStatus = SearchCommandStatus.Pending,
|
||||
JobRunId = ContextProvider.TryGetJobRunId(),
|
||||
InstanceType = ContextProvider.Get(nameof(InstanceType)) is InstanceType it ? it : null,
|
||||
InstanceUrl = (ContextProvider.Get(ContextProvider.Keys.ArrInstanceUrl) as Uri)?.ToString(),
|
||||
DownloadClientType = ContextProvider.Get(ContextProvider.Keys.DownloadClientType) is DownloadClientTypeName dct ? dct : null,
|
||||
DownloadClientName = ContextProvider.Get(ContextProvider.Keys.DownloadClientName) as string,
|
||||
CycleId = cycleId,
|
||||
};
|
||||
|
||||
eventEntity.IsDryRun = await _dryRunInterceptor.IsDryRunEnabled();
|
||||
await SaveEventToDatabase(eventEntity);
|
||||
await NotifyClientsAsync(eventEntity);
|
||||
await _notificationPublisher.NotifySearchTriggered(instanceName, itemCount, itemList);
|
||||
|
||||
return eventEntity.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing search event with completion status and optional result data
|
||||
/// </summary>
|
||||
public async Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, object? resultData = null)
|
||||
{
|
||||
var existingEvent = await _context.Events.FindAsync(eventId);
|
||||
if (existingEvent is null)
|
||||
{
|
||||
_logger.LogWarning("Could not find search event {EventId} to update completion status", eventId);
|
||||
return;
|
||||
}
|
||||
|
||||
existingEvent.SearchStatus = status;
|
||||
existingEvent.CompletedAt = DateTime.UtcNow;
|
||||
|
||||
if (resultData is not null)
|
||||
{
|
||||
// Merge result data into existing Data JSON
|
||||
var existingData = existingEvent.Data is not null
|
||||
? JsonSerializer.Deserialize<Dictionary<string, object>>(existingEvent.Data)
|
||||
: new Dictionary<string, object>();
|
||||
|
||||
var resultJson = JsonSerializer.Serialize(resultData, new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
});
|
||||
var resultDict = JsonSerializer.Deserialize<Dictionary<string, object>>(resultJson);
|
||||
|
||||
if (existingData is not null && resultDict is not null)
|
||||
{
|
||||
foreach (var kvp in resultDict)
|
||||
{
|
||||
existingData[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
existingEvent.Data = JsonSerializer.Serialize(existingData, new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
await NotifyClientsAsync(existingEvent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an event alerting that search was not triggered for an item
|
||||
/// </summary>
|
||||
|
||||
@@ -19,4 +19,8 @@ public interface IEventPublisher
|
||||
Task PublishRecurringItem(string hash, string itemName, int strikeCount);
|
||||
|
||||
Task PublishSearchNotTriggered(string hash, string itemName);
|
||||
|
||||
Task<Guid> PublishSearchTriggered(string instanceName, int itemCount, IEnumerable<string> items, SeekerSearchType searchType, Guid? cycleId = null);
|
||||
|
||||
Task PublishSearchCompleted(Guid eventId, SearchCommandStatus status, object? resultData = null);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
@@ -41,8 +42,8 @@ public abstract class ArrClient : IArrClient
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -53,18 +54,46 @@ public abstract class ArrClient : IArrClient
|
||||
_logger.LogError("queue list failed | {uri}", uriBuilder.Uri);
|
||||
throw;
|
||||
}
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
QueueListResponse? queueResponse = JsonConvert.DeserializeObject<QueueListResponse>(responseBody);
|
||||
|
||||
QueueListResponse? queueResponse = await DeserializeStreamAsync<QueueListResponse>(response);
|
||||
|
||||
if (queueResponse is null)
|
||||
{
|
||||
throw new Exception($"unrecognized queue list response | {uriBuilder.Uri} | {responseBody}");
|
||||
throw new Exception($"unrecognized queue list response | {uriBuilder.Uri}");
|
||||
}
|
||||
|
||||
return queueResponse;
|
||||
}
|
||||
|
||||
public async Task<int> GetActiveDownloadCountAsync(ArrInstance arrInstance)
|
||||
{
|
||||
int count = 0;
|
||||
int page = 1;
|
||||
int processed = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
QueueListResponse response = await GetQueueItemsAsync(arrInstance, page);
|
||||
|
||||
if (response.Records.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
count += response.Records.Count(r => r.SizeLeft > 0);
|
||||
processed += response.Records.Count;
|
||||
|
||||
if (processed >= response.TotalRecords)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public virtual async Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes)
|
||||
{
|
||||
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
|
||||
@@ -166,7 +195,7 @@ public abstract class ArrClient : IArrClient
|
||||
}
|
||||
}
|
||||
|
||||
public abstract Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
|
||||
public abstract Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
|
||||
|
||||
public bool IsRecordValid(QueueRecord record)
|
||||
{
|
||||
@@ -197,6 +226,23 @@ public abstract class ArrClient : IArrClient
|
||||
_logger.LogDebug("Connection test successful for {url}", arrInstance.Url);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<ArrCommandStatus> GetCommandStatusAsync(ArrInstance arrInstance, long commandId)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command/{commandId}";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await DeserializeStreamAsync<ArrCommandStatus>(response);
|
||||
|
||||
return result ?? new ArrCommandStatus(commandId, "unknown", null);
|
||||
}
|
||||
|
||||
protected abstract string GetSystemStatusUrlPath();
|
||||
|
||||
protected abstract string GetQueueUrlPath();
|
||||
@@ -221,6 +267,26 @@ public abstract class ArrClient : IArrClient
|
||||
return response;
|
||||
}
|
||||
|
||||
protected static async Task<T?> DeserializeStreamAsync<T>(HttpResponseMessage response)
|
||||
{
|
||||
using Stream stream = await response.Content.ReadAsStreamAsync();
|
||||
using StreamReader sr = new(stream);
|
||||
using JsonTextReader reader = new(sr);
|
||||
return JsonSerializer.CreateDefault().Deserialize<T>(reader);
|
||||
}
|
||||
|
||||
protected static async Task<long?> ReadCommandIdAsync(HttpResponseMessage response)
|
||||
{
|
||||
CommandIdResponse? result = await DeserializeStreamAsync<CommandIdResponse>(response);
|
||||
return result?.Id;
|
||||
}
|
||||
|
||||
private sealed class CommandIdResponse
|
||||
{
|
||||
[JsonProperty("id")]
|
||||
public long? Id { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the failed import record should be skipped
|
||||
/// </summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
@@ -12,9 +13,17 @@ public interface IArrClient
|
||||
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes);
|
||||
|
||||
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason);
|
||||
|
||||
Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Triggers a search for the specified items and returns the arr command IDs
|
||||
/// </summary>
|
||||
Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of an arr command by its ID
|
||||
/// </summary>
|
||||
Task<ArrCommandStatus> GetCommandStatusAsync(ArrInstance arrInstance, long commandId);
|
||||
|
||||
bool IsRecordValid(QueueRecord record);
|
||||
|
||||
/// <summary>
|
||||
@@ -30,4 +39,10 @@ public interface IArrClient
|
||||
/// <param name="arrInstance">The instance to test connection to</param>
|
||||
/// <returns>Task that completes when the connection test is done</returns>
|
||||
Task HealthCheckAsync(ArrInstance arrInstance);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of items actively downloading (SizeLeft > 0) across all queue pages.
|
||||
/// Items that are completed, import-blocked, or otherwise finished are not counted.
|
||||
/// </summary>
|
||||
Task<int> GetActiveDownloadCountAsync(ArrInstance arrInstance);
|
||||
}
|
||||
@@ -1,5 +1,22 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
|
||||
public interface IRadarrClient : IArrClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches all movies from a Radarr instance
|
||||
/// </summary>
|
||||
Task<List<SearchableMovie>> GetAllMoviesAsync(ArrInstance arrInstance);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches quality profiles from a Radarr instance
|
||||
/// </summary>
|
||||
Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches custom format scores for movie files in batches
|
||||
/// </summary>
|
||||
Task<Dictionary<long, int>> GetMovieFileScoresAsync(ArrInstance arrInstance, List<long> movieFileIds);
|
||||
}
|
||||
@@ -1,5 +1,32 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
|
||||
public interface ISonarrClient : IArrClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches all series from a Sonarr instance
|
||||
/// </summary>
|
||||
Task<List<SearchableSeries>> GetAllSeriesAsync(ArrInstance arrInstance);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all episodes for a specific series from a Sonarr instance
|
||||
/// </summary>
|
||||
Task<List<SearchableEpisode>> GetEpisodesAsync(ArrInstance arrInstance, long seriesId);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches quality profiles from a Sonarr instance
|
||||
/// </summary>
|
||||
Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches episode file metadata for a specific series, including quality cutoff status
|
||||
/// </summary>
|
||||
Task<List<ArrEpisodeFile>> GetEpisodeFilesAsync(ArrInstance arrInstance, long seriesId);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches custom format scores for episode files in batches
|
||||
/// </summary>
|
||||
Task<Dictionary<long, int>> GetEpisodeFileScoresAsync(ArrInstance arrInstance, List<long> episodeFileIds);
|
||||
}
|
||||
@@ -50,11 +50,11 @@ public class LidarrClient : ArrClient, ILidarrClient
|
||||
return query;
|
||||
}
|
||||
|
||||
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
|
||||
public override async Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
|
||||
{
|
||||
if (items?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
@@ -85,6 +85,8 @@ public class LidarrClient : ArrClient, ILidarrClient
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public override bool HasContentId(QueueRecord record) => record.ArtistId is not 0 && record.AlbumId is not 0;
|
||||
@@ -137,15 +139,14 @@ public class LidarrClient : ArrClient, ILidarrClient
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/album";
|
||||
uriBuilder.Query = string.Join('&', albumIds.Select(x => $"albumIds={x}"));
|
||||
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request);
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
return JsonConvert.DeserializeObject<List<Album>>(responseBody);
|
||||
return await DeserializeStreamAsync<List<Album>>(response);
|
||||
}
|
||||
|
||||
private List<LidarrCommand> GetSearchCommands(HashSet<SearchItem> items)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Entities.Radarr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
@@ -50,24 +51,24 @@ public class RadarrClient : ArrClient, IRadarrClient
|
||||
return query;
|
||||
}
|
||||
|
||||
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
|
||||
public override async Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
|
||||
{
|
||||
if (items?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
List<long> ids = items.Select(item => item.Id).ToList();
|
||||
|
||||
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
|
||||
|
||||
|
||||
RadarrCommand command = new()
|
||||
{
|
||||
Name = "MoviesSearch",
|
||||
MovieIds = ids,
|
||||
};
|
||||
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
|
||||
request.Content = new StringContent(
|
||||
JsonConvert.SerializeObject(command),
|
||||
@@ -81,9 +82,18 @@ public class RadarrClient : ArrClient, IRadarrClient
|
||||
try
|
||||
{
|
||||
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
|
||||
response?.Dispose();
|
||||
|
||||
|
||||
if (response is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
long? commandId = await ReadCommandIdAsync(response);
|
||||
response.Dispose();
|
||||
|
||||
_logger.LogInformation("{log}", GetSearchLog(arrInstance.Url, command, true, logContext));
|
||||
|
||||
return commandId.HasValue ? [commandId.Value] : [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -130,18 +140,77 @@ public class RadarrClient : ArrClient, IRadarrClient
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<List<SearchableMovie>> GetAllMoviesAsync(ArrInstance arrInstance)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using Stream stream = await response.Content.ReadAsStreamAsync();
|
||||
using StreamReader sr = new(stream);
|
||||
using JsonTextReader reader = new(sr);
|
||||
JsonSerializer serializer = JsonSerializer.CreateDefault();
|
||||
return serializer.Deserialize<List<SearchableMovie>>(reader) ?? [];
|
||||
}
|
||||
|
||||
public async Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/qualityprofile";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await DeserializeStreamAsync<List<ArrQualityProfile>>(response) ?? [];
|
||||
}
|
||||
|
||||
public async Task<Dictionary<long, int>> GetMovieFileScoresAsync(ArrInstance arrInstance, List<long> movieFileIds)
|
||||
{
|
||||
Dictionary<long, int> scores = new();
|
||||
|
||||
// Batch in chunks of 100 to avoid 414 URI Too Long
|
||||
foreach (long[] batch in movieFileIds.Chunk(100))
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/moviefile";
|
||||
uriBuilder.Query = string.Join('&', batch.Select(id => $"movieFileIds={id}"));
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
List<MediaFileScore> files = await DeserializeStreamAsync<List<MediaFileScore>>(response) ?? [];
|
||||
|
||||
foreach (MediaFileScore file in files)
|
||||
{
|
||||
scores[file.Id] = file.CustomFormatScore;
|
||||
}
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
private async Task<Movie?> GetMovie(ArrInstance arrInstance, long movieId)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie/{movieId}";
|
||||
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
return JsonConvert.DeserializeObject<Movie>(responseBody);
|
||||
|
||||
return await DeserializeStreamAsync<Movie>(response);
|
||||
}
|
||||
}
|
||||
@@ -50,11 +50,11 @@ public class ReadarrClient : ArrClient, IReadarrClient
|
||||
return query;
|
||||
}
|
||||
|
||||
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
|
||||
public override async Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
|
||||
{
|
||||
if (items?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
List<long> ids = items.Select(item => item.Id).ToList();
|
||||
@@ -90,6 +90,8 @@ public class ReadarrClient : ArrClient, IReadarrClient
|
||||
_logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext));
|
||||
throw;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public override bool HasContentId(QueueRecord record) => record.AuthorId is not 0 && record.BookId is not 0;
|
||||
@@ -134,14 +136,13 @@ public class ReadarrClient : ArrClient, IReadarrClient
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v1/book/{bookId}";
|
||||
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
return JsonConvert.DeserializeObject<Book>(responseBody);
|
||||
|
||||
return await DeserializeStreamAsync<Book>(response);
|
||||
}
|
||||
}
|
||||
@@ -53,16 +53,18 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
return query;
|
||||
}
|
||||
|
||||
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
|
||||
public override async Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
|
||||
{
|
||||
if (items?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
List<long> commandIds = [];
|
||||
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/command";
|
||||
|
||||
|
||||
foreach (SonarrCommand command in GetSearchCommands(items.Cast<SeriesSearchItem>().ToHashSet()))
|
||||
{
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, uriBuilder.Uri);
|
||||
@@ -78,8 +80,18 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
try
|
||||
{
|
||||
HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync<HttpResponseMessage>(SendRequestAsync, request);
|
||||
response?.Dispose();
|
||||
|
||||
|
||||
if (response is not null)
|
||||
{
|
||||
long? commandId = await ReadCommandIdAsync(response);
|
||||
response.Dispose();
|
||||
|
||||
if (commandId.HasValue)
|
||||
{
|
||||
commandIds.Add(commandId.Value);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("{log}", GetSearchLog(command.SearchType, arrInstance.Url, command, true, logContext));
|
||||
}
|
||||
catch
|
||||
@@ -88,6 +100,8 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
return commandIds;
|
||||
}
|
||||
|
||||
public override bool HasContentId(QueueRecord record) => record.EpisodeId is not 0 && record.SeriesId is not 0;
|
||||
@@ -195,35 +209,123 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<List<SearchableSeries>> GetAllSeriesAsync(ArrInstance arrInstance)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using Stream stream = await response.Content.ReadAsStreamAsync();
|
||||
using StreamReader sr = new(stream);
|
||||
using JsonTextReader reader = new(sr);
|
||||
JsonSerializer serializer = JsonSerializer.CreateDefault();
|
||||
return serializer.Deserialize<List<SearchableSeries>>(reader) ?? [];
|
||||
}
|
||||
|
||||
public async Task<List<SearchableEpisode>> GetEpisodesAsync(ArrInstance arrInstance, long seriesId)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episode";
|
||||
uriBuilder.Query = $"seriesId={seriesId}";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await DeserializeStreamAsync<List<SearchableEpisode>>(response) ?? [];
|
||||
}
|
||||
|
||||
public async Task<List<ArrEpisodeFile>> GetEpisodeFilesAsync(ArrInstance arrInstance, long seriesId)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episodefile";
|
||||
uriBuilder.Query = $"seriesId={seriesId}";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await DeserializeStreamAsync<List<ArrEpisodeFile>>(response) ?? [];
|
||||
}
|
||||
|
||||
public async Task<List<ArrQualityProfile>> GetQualityProfilesAsync(ArrInstance arrInstance)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/qualityprofile";
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await DeserializeStreamAsync<List<ArrQualityProfile>>(response) ?? [];
|
||||
}
|
||||
|
||||
public async Task<Dictionary<long, int>> GetEpisodeFileScoresAsync(ArrInstance arrInstance, List<long> episodeFileIds)
|
||||
{
|
||||
Dictionary<long, int> scores = new();
|
||||
|
||||
// Batch in chunks of 100 to avoid 414 URI Too Long
|
||||
foreach (long[] batch in episodeFileIds.Chunk(100))
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episodefile";
|
||||
uriBuilder.Query = string.Join('&', batch.Select(id => $"episodeFileIds={id}"));
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
List<MediaFileScore> files = await DeserializeStreamAsync<List<MediaFileScore>>(response) ?? [];
|
||||
|
||||
foreach (MediaFileScore file in files)
|
||||
{
|
||||
scores[file.Id] = file.CustomFormatScore;
|
||||
}
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
private async Task<List<Episode>?> GetEpisodesAsync(ArrInstance arrInstance, List<long> episodeIds)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/episode";
|
||||
uriBuilder.Query = string.Join('&', episodeIds.Select(x => $"episodeIds={x}"));
|
||||
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
return JsonConvert.DeserializeObject<List<Episode>>(responseBody);
|
||||
|
||||
return await DeserializeStreamAsync<List<Episode>>(response);
|
||||
}
|
||||
|
||||
private async Task<Series?> GetSeriesAsync(ArrInstance arrInstance, long seriesId)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/series/{seriesId}";
|
||||
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
return JsonConvert.DeserializeObject<Series>(responseBody);
|
||||
|
||||
return await DeserializeStreamAsync<Series>(response);
|
||||
}
|
||||
|
||||
private List<SonarrCommand> GetSearchCommands(HashSet<SeriesSearchItem> items)
|
||||
|
||||
@@ -53,11 +53,11 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
|
||||
return query;
|
||||
}
|
||||
|
||||
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
|
||||
public override async Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
|
||||
{
|
||||
if (items?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
@@ -88,6 +88,8 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public override bool HasContentId(QueueRecord record) => record.EpisodeId is not 0 && record.SeriesId is not 0;
|
||||
@@ -204,10 +206,10 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
HttpResponseMessage response = await SendRequestAsync(request);
|
||||
string responseContent = await response.Content.ReadAsStringAsync();
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return JsonConvert.DeserializeObject<List<Episode>>(responseContent);
|
||||
return await DeserializeStreamAsync<List<Episode>>(response);
|
||||
}
|
||||
|
||||
private async Task<Series?> GetSeriesAsync(ArrInstance arrInstance, long seriesId)
|
||||
@@ -218,10 +220,10 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
HttpResponseMessage response = await SendRequestAsync(request);
|
||||
string responseContent = await response.Content.ReadAsStringAsync();
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return JsonConvert.DeserializeObject<Series>(responseContent);
|
||||
return await DeserializeStreamAsync<Series>(response);
|
||||
}
|
||||
|
||||
private List<WhisparrV2Command> GetSearchCommands(HashSet<SeriesSearchItem> items)
|
||||
|
||||
@@ -51,11 +51,11 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
|
||||
return query;
|
||||
}
|
||||
|
||||
public override async Task SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
|
||||
public override async Task<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
|
||||
{
|
||||
if (items?.Count is null or 0)
|
||||
{
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
List<long> ids = items.Select(item => item.Id).ToList();
|
||||
@@ -91,6 +91,8 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
|
||||
_logger.LogError("{log}", GetSearchLog(arrInstance.Url, command, false, logContext));
|
||||
throw;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public override bool HasContentId(QueueRecord record) => record.MovieId is not 0;
|
||||
@@ -135,14 +137,13 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/movie/{movieId}";
|
||||
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request);
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
return JsonConvert.DeserializeObject<Movie>(responseBody);
|
||||
|
||||
return await DeserializeStreamAsync<Movie>(response);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ public sealed class BlacklistSynchronizer : IHandler
|
||||
_dryRunInterceptor = dryRunInterceptor;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync()
|
||||
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
BlacklistSyncConfig config = await _dataContext.BlacklistSyncConfigs
|
||||
.AsNoTracking()
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadHunter.Consumers;
|
||||
|
||||
public class DownloadHunterConsumer<T> : IConsumer<DownloadHuntRequest<T>>
|
||||
where T : SearchItem
|
||||
{
|
||||
private readonly ILogger<DownloadHunterConsumer<T>> _logger;
|
||||
private readonly IDownloadHunter _downloadHunter;
|
||||
|
||||
public DownloadHunterConsumer(ILogger<DownloadHunterConsumer<T>> logger, IDownloadHunter downloadHunter)
|
||||
{
|
||||
_logger = logger;
|
||||
_downloadHunter = downloadHunter;
|
||||
}
|
||||
|
||||
public async Task Consume(ConsumeContext<DownloadHuntRequest<T>> context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _downloadHunter.HuntDownloadsAsync(context.Message);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception,
|
||||
"failed to search for replacement | {title} | {url}",
|
||||
context.Message.Record.Title,
|
||||
context.Message.Instance.Url
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadHunter;
|
||||
|
||||
public sealed class DownloadHunter : IDownloadHunter
|
||||
{
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DownloadHunter(
|
||||
DataContext dataContext,
|
||||
IArrClientFactory arrClientFactory,
|
||||
TimeProvider timeProvider
|
||||
)
|
||||
{
|
||||
_dataContext = dataContext;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task HuntDownloadsAsync<T>(DownloadHuntRequest<T> request)
|
||||
where T : SearchItem
|
||||
{
|
||||
var generalConfig = await _dataContext.GeneralConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
if (!generalConfig.SearchEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var arrClient = _arrClientFactory.GetClient(request.InstanceType, request.Instance.Version);
|
||||
await arrClient.SearchItemsAsync(request.Instance, [request.SearchItem]);
|
||||
|
||||
// Prevent manual db edits
|
||||
if (generalConfig.SearchDelay < Constants.MinSearchDelaySeconds)
|
||||
{
|
||||
generalConfig.SearchDelay = Constants.DefaultSearchDelaySeconds;
|
||||
}
|
||||
|
||||
// Prevent tracker spamming
|
||||
await Task.Delay(TimeSpan.FromSeconds(generalConfig.SearchDelay), _timeProvider);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
|
||||
using Data.Models.Arr;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadHunter.Interfaces;
|
||||
|
||||
public interface IDownloadHunter
|
||||
{
|
||||
Task HuntDownloadsAsync<T>(DownloadHuntRequest<T> request) where T : SearchItem;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Data.Models.Arr;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
|
||||
|
||||
public sealed record DownloadHuntRequest<T>
|
||||
where T : SearchItem
|
||||
{
|
||||
public required InstanceType InstanceType { get; init; }
|
||||
|
||||
public required ArrInstance Instance { get; init; }
|
||||
|
||||
public required T SearchItem { get; init; }
|
||||
|
||||
public required QueueRecord Record { get; init; }
|
||||
|
||||
public required Guid JobRunId { get; init; }
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadHunter.Models;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Cleanuparr.Infrastructure.Features.ItemStriker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Data.Models.Arr;
|
||||
using MassTransit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -24,27 +22,27 @@ namespace Cleanuparr.Infrastructure.Features.DownloadRemover;
|
||||
public sealed class QueueItemRemover : IQueueItemRemover
|
||||
{
|
||||
private readonly ILogger<QueueItemRemover> _logger;
|
||||
private readonly IBus _messageBus;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
private readonly IEventPublisher _eventPublisher;
|
||||
private readonly EventsContext _eventsContext;
|
||||
private readonly DataContext _dataContext;
|
||||
|
||||
public QueueItemRemover(
|
||||
ILogger<QueueItemRemover> logger,
|
||||
IBus messageBus,
|
||||
IMemoryCache cache,
|
||||
IArrClientFactory arrClientFactory,
|
||||
IEventPublisher eventPublisher,
|
||||
EventsContext eventsContext
|
||||
EventsContext eventsContext,
|
||||
DataContext dataContext
|
||||
)
|
||||
{
|
||||
_logger = logger;
|
||||
_messageBus = messageBus;
|
||||
_cache = cache;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
_eventPublisher = eventPublisher;
|
||||
_eventsContext = eventsContext;
|
||||
_dataContext = dataContext;
|
||||
}
|
||||
|
||||
public async Task RemoveQueueItemAsync<T>(QueueItemRemoveRequest<T> request)
|
||||
@@ -90,14 +88,26 @@ public sealed class QueueItemRemover : IQueueItemRemover
|
||||
return;
|
||||
}
|
||||
|
||||
await _messageBus.Publish(new DownloadHuntRequest<T>
|
||||
SeekerConfig seekerConfig = await _dataContext.SeekerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
if (!seekerConfig.SearchEnabled)
|
||||
{
|
||||
InstanceType = request.InstanceType,
|
||||
Instance = request.Instance,
|
||||
SearchItem = request.SearchItem,
|
||||
Record = request.Record,
|
||||
JobRunId = request.JobRunId
|
||||
_logger.LogDebug("Search not triggered | {name}", request.Record.Title);
|
||||
return;
|
||||
}
|
||||
|
||||
_dataContext.SearchQueue.Add(new SearchQueueItem
|
||||
{
|
||||
ArrInstanceId = request.Instance.Id,
|
||||
ItemId = request.SearchItem.Id,
|
||||
SeriesId = (request.SearchItem as SeriesSearchItem)?.SeriesId,
|
||||
SearchType = (request.SearchItem as SeriesSearchItem)?.SearchType.ToString(),
|
||||
Title = request.Record.Title,
|
||||
});
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Periodically syncs custom format scores from Radarr/Sonarr instances.
|
||||
/// Tracks score changes over time for dashboard display and Seeker filtering.
|
||||
/// </summary>
|
||||
public sealed class CustomFormatScoreSyncer : IHandler
|
||||
{
|
||||
private const int ChunkSize = 200;
|
||||
private static readonly TimeSpan HistoryRetention = TimeSpan.FromDays(120);
|
||||
|
||||
private readonly ILogger<CustomFormatScoreSyncer> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IRadarrClient _radarrClient;
|
||||
private readonly ISonarrClient _sonarrClient;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IHubContext<AppHub> _hubContext;
|
||||
|
||||
public CustomFormatScoreSyncer(
|
||||
ILogger<CustomFormatScoreSyncer> logger,
|
||||
DataContext dataContext,
|
||||
IRadarrClient radarrClient,
|
||||
ISonarrClient sonarrClient,
|
||||
TimeProvider timeProvider,
|
||||
IHubContext<AppHub> hubContext)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_radarrClient = radarrClient;
|
||||
_sonarrClient = sonarrClient;
|
||||
_timeProvider = timeProvider;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
SeekerConfig config = await _dataContext.SeekerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
if (!config.UseCustomFormatScore)
|
||||
{
|
||||
_logger.LogTrace("Custom format score tracking is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
List<SeekerInstanceConfig> instanceConfigs = await _dataContext.SeekerInstanceConfigs
|
||||
.Include(s => s.ArrInstance)
|
||||
.ThenInclude(a => a.ArrConfig)
|
||||
.Where(s => s.Enabled && s.ArrInstance.Enabled)
|
||||
.ToListAsync();
|
||||
|
||||
instanceConfigs = instanceConfigs
|
||||
.Where(s => s.ArrInstance.ArrConfig.Type is InstanceType.Sonarr or InstanceType.Radarr)
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug("CF score sync found {Count} enabled instance(s): {Instances}",
|
||||
instanceConfigs.Count,
|
||||
string.Join(", ", instanceConfigs.Select(c => $"{c.ArrInstance.Name} ({c.ArrInstance.ArrConfig.Type})")));
|
||||
|
||||
if (instanceConfigs.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No enabled instances for CF score sync");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (SeekerInstanceConfig instanceConfig in instanceConfigs)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SyncInstanceAsync(instanceConfig.ArrInstance, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to sync CF scores for {InstanceName}",
|
||||
instanceConfig.ArrInstance.Name);
|
||||
}
|
||||
}
|
||||
|
||||
await CleanupOldHistoryAsync();
|
||||
|
||||
await _hubContext.Clients.All.SendAsync("CfScoresUpdated");
|
||||
}
|
||||
|
||||
private async Task SyncInstanceAsync(ArrInstance arrInstance, CancellationToken cancellationToken)
|
||||
{
|
||||
InstanceType instanceType = arrInstance.ArrConfig.Type;
|
||||
|
||||
_logger.LogDebug("Syncing CF scores for {InstanceType} instance: {InstanceName}",
|
||||
instanceType, arrInstance.Name);
|
||||
|
||||
if (instanceType == InstanceType.Radarr)
|
||||
{
|
||||
await SyncRadarrAsync(arrInstance);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SyncSonarrAsync(arrInstance, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SyncRadarrAsync(ArrInstance arrInstance)
|
||||
{
|
||||
List<ArrQualityProfile> profiles = await _radarrClient.GetQualityProfilesAsync(arrInstance);
|
||||
Dictionary<int, ArrQualityProfile> profileMap = profiles.ToDictionary(p => p.Id);
|
||||
|
||||
_logger.LogTrace("[Radarr] {InstanceName}: fetched {ProfileCount} quality profile(s)",
|
||||
arrInstance.Name, profiles.Count);
|
||||
|
||||
List<SearchableMovie> allMovies = await _radarrClient.GetAllMoviesAsync(arrInstance);
|
||||
|
||||
List<(SearchableMovie Movie, long FileId)> moviesWithFiles = allMovies
|
||||
.Where(m => m.HasFile && m.MovieFile is not null)
|
||||
.Select(m => (Movie: m, FileId: m.MovieFile!.Id))
|
||||
.Where(x => x.FileId > 0)
|
||||
.ToList();
|
||||
|
||||
_logger.LogTrace("[Radarr] {InstanceName}: found {TotalMovies} total movies, {WithFiles} with files",
|
||||
arrInstance.Name, allMovies.Count, moviesWithFiles.Count);
|
||||
|
||||
DateTime now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
// Touch entries for movies that still exist but have no file (e.g., RSS upgrade in progress)
|
||||
HashSet<long> movieIdsWithFiles = moviesWithFiles.Select(x => x.Movie.Id).ToHashSet();
|
||||
List<long> movieIdsWithoutFiles = allMovies
|
||||
.Where(m => !movieIdsWithFiles.Contains(m.Id))
|
||||
.Select(m => m.Id)
|
||||
.ToList();
|
||||
|
||||
foreach (long[] touchChunk in movieIdsWithoutFiles.Chunk(ChunkSize))
|
||||
{
|
||||
List<long> chunkList = touchChunk.ToList();
|
||||
await _dataContext.CustomFormatScoreEntries
|
||||
.Where(e => e.ArrInstanceId == arrInstance.Id
|
||||
&& e.ItemType == InstanceType.Radarr
|
||||
&& chunkList.Contains(e.ExternalItemId))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(e => e.LastSyncedAt, now));
|
||||
}
|
||||
|
||||
int totalSynced = 0;
|
||||
int totalSkipped = 0;
|
||||
|
||||
foreach ((SearchableMovie Movie, long FileId)[] chunk in moviesWithFiles.Chunk(ChunkSize))
|
||||
{
|
||||
List<long> fileIds = chunk.Select(x => x.FileId).ToList();
|
||||
Dictionary<long, int> scores = await _radarrClient.GetMovieFileScoresAsync(arrInstance, fileIds);
|
||||
|
||||
_logger.LogTrace("[Radarr] {InstanceName}: chunk of {FileCount} file IDs returned {ScoreCount} score(s)",
|
||||
arrInstance.Name, fileIds.Count, scores.Count);
|
||||
|
||||
List<long> movieIds = chunk.Select(x => x.Movie.Id).ToList();
|
||||
Dictionary<long, CustomFormatScoreEntry> existingEntries = await _dataContext.CustomFormatScoreEntries
|
||||
.Where(e => e.ArrInstanceId == arrInstance.Id
|
||||
&& e.ItemType == InstanceType.Radarr
|
||||
&& movieIds.Contains(e.ExternalItemId))
|
||||
.ToDictionaryAsync(e => e.ExternalItemId);
|
||||
|
||||
foreach ((SearchableMovie movie, long fileId) in chunk)
|
||||
{
|
||||
if (!scores.TryGetValue(fileId, out int cfScore))
|
||||
{
|
||||
totalSkipped++;
|
||||
// Touch existing entry to prevent stale cleanup — movie still exists
|
||||
if (existingEntries.TryGetValue(movie.Id, out CustomFormatScoreEntry? skippedEntry))
|
||||
{
|
||||
skippedEntry.LastSyncedAt = now;
|
||||
}
|
||||
_logger.LogTrace("[Radarr] {InstanceName}: skipping movie '{Title}' (fileId={FileId}) — no score returned",
|
||||
arrInstance.Name, movie.Title, fileId);
|
||||
continue;
|
||||
}
|
||||
|
||||
profileMap.TryGetValue(movie.QualityProfileId, out ArrQualityProfile? profile);
|
||||
int cutoffScore = profile?.CutoffFormatScore ?? 0;
|
||||
string profileName = profile?.Name ?? "Unknown";
|
||||
|
||||
existingEntries.TryGetValue(movie.Id, out CustomFormatScoreEntry? existing);
|
||||
UpsertCustomFormatScore(existing, arrInstance.Id, movie.Id, 0, InstanceType.Radarr, movie.Title, fileId, cfScore, cutoffScore, profileName, movie.Monitored, now);
|
||||
|
||||
totalSynced++;
|
||||
}
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await CleanupStaleEntriesAsync(arrInstance.Id, InstanceType.Radarr, now);
|
||||
|
||||
_logger.LogInformation("[Radarr] Synced CF scores for {Count} movies on {InstanceName} ({Skipped} skipped)",
|
||||
totalSynced, arrInstance.Name, totalSkipped);
|
||||
}
|
||||
|
||||
private async Task SyncSonarrAsync(ArrInstance arrInstance, CancellationToken cancellationToken)
|
||||
{
|
||||
List<ArrQualityProfile> profiles = await _sonarrClient.GetQualityProfilesAsync(arrInstance);
|
||||
Dictionary<int, ArrQualityProfile> profileMap = profiles.ToDictionary(p => p.Id);
|
||||
|
||||
_logger.LogTrace("[Sonarr] {InstanceName}: fetched {ProfileCount} quality profile(s)",
|
||||
arrInstance.Name, profiles.Count);
|
||||
|
||||
List<SearchableSeries> allSeries = await _sonarrClient.GetAllSeriesAsync(arrInstance);
|
||||
|
||||
_logger.LogTrace("[Sonarr] {InstanceName}: found {SeriesCount} total series",
|
||||
arrInstance.Name, allSeries.Count);
|
||||
|
||||
DateTime now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
int totalSynced = 0;
|
||||
int totalSkipped = 0;
|
||||
|
||||
foreach (SearchableSeries[] chunk in allSeries.Chunk(ChunkSize))
|
||||
{
|
||||
// Collect all episodes with files for this chunk of series
|
||||
List<(SearchableSeries Series, SearchableEpisode Episode, long FileId, int CfScore, bool IsMonitored)> itemsInChunk = [];
|
||||
List<(long SeriesId, long EpisodeId)> episodesToTouch = [];
|
||||
|
||||
foreach (SearchableSeries series in chunk)
|
||||
{
|
||||
try
|
||||
{
|
||||
List<SearchableEpisode> episodes = await _sonarrClient.GetEpisodesAsync(arrInstance, series.Id);
|
||||
List<ArrEpisodeFile> episodeFiles = await _sonarrClient.GetEpisodeFilesAsync(arrInstance, series.Id);
|
||||
|
||||
// Build a map of fileId -> episode file
|
||||
Dictionary<long, ArrEpisodeFile> fileMap = episodeFiles.ToDictionary(f => f.Id);
|
||||
|
||||
// Match episodes to their files via EpisodeFileId
|
||||
int matched = 0;
|
||||
foreach (SearchableEpisode episode in episodes)
|
||||
{
|
||||
if (episode.EpisodeFileId > 0 && fileMap.TryGetValue(episode.EpisodeFileId, out ArrEpisodeFile? file))
|
||||
{
|
||||
itemsInChunk.Add((series, episode, file.Id, file.CustomFormatScore, series.Monitored && episode.Monitored));
|
||||
matched++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Episode exists but has no file — touch its entry to prevent stale cleanup
|
||||
episodesToTouch.Add((series.Id, episode.Id));
|
||||
if (episode.EpisodeFileId > 0)
|
||||
{
|
||||
totalSkipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogTrace("[Sonarr] {InstanceName}: series '{SeriesTitle}' (id={SeriesId}) has {TotalEpisodes} episodes, {FileCount} files, {Matched} matched",
|
||||
arrInstance.Name, series.Title, series.Id, episodes.Count, episodeFiles.Count, matched);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[Sonarr] Failed to fetch episodes for series '{SeriesTitle}' (id={SeriesId}) on {InstanceName}",
|
||||
series.Title, series.Id, arrInstance.Name);
|
||||
}
|
||||
|
||||
// Rate limit to avoid overloading the Sonarr API
|
||||
await Task.Delay(Random.Shared.Next(100, 500), cancellationToken);
|
||||
}
|
||||
|
||||
if (itemsInChunk.Count > 0)
|
||||
{
|
||||
List<long> seriesIds = itemsInChunk.Select(x => x.Series.Id).Distinct().ToList();
|
||||
Dictionary<(long, long), CustomFormatScoreEntry> existingEntries = await _dataContext.CustomFormatScoreEntries
|
||||
.Where(e => e.ArrInstanceId == arrInstance.Id
|
||||
&& e.ItemType == InstanceType.Sonarr
|
||||
&& seriesIds.Contains(e.ExternalItemId))
|
||||
.ToDictionaryAsync(e => (e.ExternalItemId, e.EpisodeId));
|
||||
|
||||
foreach ((SearchableSeries series, SearchableEpisode episode, long fileId, int cfScore, bool isMonitored) in itemsInChunk)
|
||||
{
|
||||
profileMap.TryGetValue(series.QualityProfileId, out ArrQualityProfile? profile);
|
||||
int cutoffScore = profile?.CutoffFormatScore ?? 0;
|
||||
string profileName = profile?.Name ?? "Unknown";
|
||||
|
||||
string title = $"{series.Title} S{episode.SeasonNumber:D2}E{episode.EpisodeNumber:D2}";
|
||||
|
||||
existingEntries.TryGetValue((series.Id, episode.Id), out CustomFormatScoreEntry? existing);
|
||||
UpsertCustomFormatScore(existing, arrInstance.Id, series.Id, episode.Id, InstanceType.Sonarr, title, fileId, cfScore, cutoffScore, profileName, isMonitored, now);
|
||||
|
||||
totalSynced++;
|
||||
}
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogTrace("[Sonarr] {InstanceName}: chunk of {ChunkSize} series yielded 0 episodes with files",
|
||||
arrInstance.Name, chunk.Length);
|
||||
}
|
||||
|
||||
// Touch entries for episodes that exist but have no file (e.g., RSS upgrade in progress)
|
||||
foreach (var group in episodesToTouch.GroupBy(x => x.SeriesId))
|
||||
{
|
||||
List<long> episodeIds = group.Select(x => x.EpisodeId).ToList();
|
||||
foreach (long[] epChunk in episodeIds.Chunk(ChunkSize))
|
||||
{
|
||||
List<long> epChunkList = epChunk.ToList();
|
||||
await _dataContext.CustomFormatScoreEntries
|
||||
.Where(e => e.ArrInstanceId == arrInstance.Id
|
||||
&& e.ItemType == InstanceType.Sonarr
|
||||
&& e.ExternalItemId == group.Key
|
||||
&& epChunkList.Contains(e.EpisodeId))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(e => e.LastSyncedAt, now));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await CleanupStaleEntriesAsync(arrInstance.Id, InstanceType.Sonarr, now);
|
||||
|
||||
_logger.LogInformation("[Sonarr] Synced CF scores for {Count} episodes on {InstanceName} ({Skipped} skipped)",
|
||||
totalSynced, arrInstance.Name, totalSkipped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a CF score entry and records history when the score changes.
|
||||
/// </summary>
|
||||
private void UpsertCustomFormatScore(
|
||||
CustomFormatScoreEntry? existing,
|
||||
Guid arrInstanceId,
|
||||
long externalItemId,
|
||||
long episodeId,
|
||||
InstanceType itemType,
|
||||
string title,
|
||||
long fileId,
|
||||
int cfScore,
|
||||
int cutoffScore,
|
||||
string profileName,
|
||||
bool isMonitored,
|
||||
DateTime now)
|
||||
{
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.CurrentScore != cfScore)
|
||||
{
|
||||
_dataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
EpisodeId = episodeId,
|
||||
ItemType = itemType,
|
||||
Title = title,
|
||||
Score = cfScore,
|
||||
CutoffScore = cutoffScore,
|
||||
RecordedAt = now,
|
||||
});
|
||||
}
|
||||
|
||||
existing.CurrentScore = cfScore;
|
||||
existing.CutoffScore = cutoffScore;
|
||||
existing.FileId = fileId;
|
||||
existing.QualityProfileName = profileName;
|
||||
existing.Title = title;
|
||||
existing.IsMonitored = isMonitored;
|
||||
existing.LastSyncedAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
_dataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
EpisodeId = episodeId,
|
||||
ItemType = itemType,
|
||||
Title = title,
|
||||
FileId = fileId,
|
||||
CurrentScore = cfScore,
|
||||
CutoffScore = cutoffScore,
|
||||
QualityProfileName = profileName,
|
||||
IsMonitored = isMonitored,
|
||||
LastSyncedAt = now,
|
||||
});
|
||||
|
||||
// Record initial score in history
|
||||
_dataContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = externalItemId,
|
||||
EpisodeId = episodeId,
|
||||
ItemType = itemType,
|
||||
Title = title,
|
||||
Score = cfScore,
|
||||
CutoffScore = cutoffScore,
|
||||
RecordedAt = now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes CF score entries and history for items not seen during the current sync.
|
||||
/// Items that still exist in the *arr app (even without files) have their LastSyncedAt
|
||||
/// updated during sync, so any entry with LastSyncedAt < syncStartTime is truly removed.
|
||||
/// </summary>
|
||||
private async Task CleanupStaleEntriesAsync(
|
||||
Guid arrInstanceId, InstanceType instanceType, DateTime syncStartTime)
|
||||
{
|
||||
List<long> staleItemIds = await _dataContext.CustomFormatScoreEntries
|
||||
.Where(e => e.ArrInstanceId == arrInstanceId
|
||||
&& e.ItemType == instanceType
|
||||
&& e.LastSyncedAt < syncStartTime)
|
||||
.Select(e => e.ExternalItemId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
if (staleItemIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _dataContext.CustomFormatScoreEntries
|
||||
.Where(e => e.ArrInstanceId == arrInstanceId
|
||||
&& e.ItemType == instanceType
|
||||
&& e.LastSyncedAt < syncStartTime)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
foreach (long[] chunk in staleItemIds.Chunk(ChunkSize))
|
||||
{
|
||||
List<long> chunkList = chunk.ToList();
|
||||
await _dataContext.CustomFormatScoreHistory
|
||||
.Where(h => h.ArrInstanceId == arrInstanceId
|
||||
&& h.ItemType == instanceType
|
||||
&& chunkList.Contains(h.ExternalItemId))
|
||||
.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
_logger.LogTrace("Cleaned up {Count} stale CF score item(s) for instance {InstanceId}",
|
||||
staleItemIds.Count, arrInstanceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes CF score history entries older than the retention period
|
||||
/// </summary>
|
||||
private async Task CleanupOldHistoryAsync()
|
||||
{
|
||||
DateTime threshold = _timeProvider.GetUtcNow().UtcDateTime - HistoryRetention;
|
||||
int deleted = await _dataContext.CustomFormatScoreHistory
|
||||
.Where(h => h.RecordedAt < threshold)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (deleted > 0)
|
||||
{
|
||||
_logger.LogDebug("Cleaned up {Count} CF score history entries older than {Days} days",
|
||||
deleted, HistoryRetention.Days);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
_hardLinkFileService = hardLinkFileService;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteInternalAsync()
|
||||
protected override async Task ExecuteInternalAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var downloadServices = await GetInitializedDownloadServicesAsync();
|
||||
|
||||
@@ -101,7 +101,7 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
_logger.LogTrace("Found {count} seeding downloads across {clientCount} clients", totalDownloads, downloadServiceToDownloadsMap.Count);
|
||||
|
||||
// wait for the downloads to appear in the arr queue
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), _timeProvider);
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), _timeProvider, cancellationToken);
|
||||
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr)), true);
|
||||
await ProcessArrConfigAsync(ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr)), true);
|
||||
|
||||
@@ -53,7 +53,7 @@ public abstract class GenericHandler : IHandler
|
||||
_dataContext = dataContext;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync()
|
||||
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
|
||||
@@ -86,11 +86,11 @@ public abstract class GenericHandler : IHandler
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
|
||||
await ExecuteInternalAsync();
|
||||
|
||||
await ExecuteInternalAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected abstract Task ExecuteInternalAsync();
|
||||
protected abstract Task ExecuteInternalAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
protected abstract Task ProcessInstanceAsync(ArrInstance instance);
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
public interface IHandler
|
||||
{
|
||||
Task ExecuteAsync();
|
||||
Task ExecuteAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
_blocklistProvider = blocklistProvider;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteInternalAsync()
|
||||
protected override async Task ExecuteInternalAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ContextProvider.Get<List<DownloadClientConfig>>(nameof(DownloadClientConfig)).Count is 0)
|
||||
{
|
||||
|
||||
@@ -36,7 +36,7 @@ public sealed class QueueCleaner : GenericHandler
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task ExecuteInternalAsync()
|
||||
protected override async Task ExecuteInternalAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<StallRule> stallRules = await _dataContext.StallRules
|
||||
.Where(r => r.Enabled)
|
||||
|
||||
928
code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs
Normal file
928
code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs
Normal file
@@ -0,0 +1,928 @@
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.Seeker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Cleanuparr.Infrastructure.Hubs;
|
||||
using Data.Models.Arr;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Jobs;
|
||||
|
||||
public sealed class Seeker : IHandler
|
||||
{
|
||||
private const double JitterFactor = 0.2;
|
||||
private const int MinJitterSeconds = 30;
|
||||
private const int MaxJitterSeconds = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Queue states that indicate an item is actively being processed.
|
||||
/// Items in these states are excluded from proactive searches.
|
||||
/// "importFailed" is intentionally excluded — failed imports should be re-searched.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ActiveQueueStates = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"downloading",
|
||||
"importing",
|
||||
"importPending",
|
||||
"importBlocked"
|
||||
};
|
||||
|
||||
private readonly ILogger<Seeker> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IRadarrClient _radarrClient;
|
||||
private readonly ISonarrClient _sonarrClient;
|
||||
private readonly IArrClientFactory _arrClientFactory;
|
||||
private readonly IArrQueueIterator _arrQueueIterator;
|
||||
private readonly IEventPublisher _eventPublisher;
|
||||
private readonly IDryRunInterceptor _dryRunInterceptor;
|
||||
private readonly IHostingEnvironment _environment;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IHubContext<AppHub> _hubContext;
|
||||
|
||||
public Seeker(
|
||||
ILogger<Seeker> logger,
|
||||
DataContext dataContext,
|
||||
IRadarrClient radarrClient,
|
||||
ISonarrClient sonarrClient,
|
||||
IArrClientFactory arrClientFactory,
|
||||
IArrQueueIterator arrQueueIterator,
|
||||
IEventPublisher eventPublisher,
|
||||
IDryRunInterceptor dryRunInterceptor,
|
||||
IHostingEnvironment environment,
|
||||
TimeProvider timeProvider,
|
||||
IHubContext<AppHub> hubContext)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_radarrClient = radarrClient;
|
||||
_sonarrClient = sonarrClient;
|
||||
_arrClientFactory = arrClientFactory;
|
||||
_arrQueueIterator = arrQueueIterator;
|
||||
_eventPublisher = eventPublisher;
|
||||
_dryRunInterceptor = dryRunInterceptor;
|
||||
_environment = environment;
|
||||
_timeProvider = timeProvider;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
SeekerConfig config = await _dataContext.SeekerConfigs
|
||||
.AsNoTracking()
|
||||
.FirstAsync();
|
||||
|
||||
if (!config.SearchEnabled)
|
||||
{
|
||||
_logger.LogDebug("Search is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
await ApplyJitter(config, cancellationToken);
|
||||
|
||||
bool isDryRun = await _dryRunInterceptor.IsDryRunEnabled();
|
||||
|
||||
// Replacement searches queued after download removal
|
||||
SearchQueueItem? replacementItem = await _dataContext.SearchQueue
|
||||
.OrderBy(q => q.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (replacementItem is not null)
|
||||
{
|
||||
await ProcessReplacementItemAsync(replacementItem, isDryRun);
|
||||
await _hubContext.Clients.All.SendAsync("SearchStatsUpdated");
|
||||
return;
|
||||
}
|
||||
|
||||
// Missing items and quality upgrades
|
||||
if (!config.ProactiveSearchEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await ProcessProactiveSearchAsync(config, isDryRun);
|
||||
|
||||
await _hubContext.Clients.All.SendAsync("SearchStatsUpdated");
|
||||
}
|
||||
|
||||
private async Task ApplyJitter(SeekerConfig config, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_environment.IsDevelopment())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int proportionalJitter = (int)(config.SearchInterval * 60 * JitterFactor);
|
||||
int maxJitterSeconds = Math.Clamp(proportionalJitter, MinJitterSeconds, MaxJitterSeconds);
|
||||
int jitterSeconds = Random.Shared.Next(0, maxJitterSeconds + 1);
|
||||
|
||||
if (jitterSeconds > 0)
|
||||
{
|
||||
_logger.LogDebug("Waiting {Jitter}s before searching", jitterSeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(jitterSeconds), _timeProvider, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessReplacementItemAsync(SearchQueueItem item, bool isDryRun)
|
||||
{
|
||||
ArrInstance? arrInstance = await _dataContext.ArrInstances
|
||||
.Include(a => a.ArrConfig)
|
||||
.FirstOrDefaultAsync(a => a.Id == item.ArrInstanceId);
|
||||
|
||||
if (arrInstance is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Skipping replacement search for '{Title}' — arr instance {InstanceId} no longer exists",
|
||||
item.Title, item.ArrInstanceId);
|
||||
_dataContext.SearchQueue.Remove(item);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
ContextProvider.Set(nameof(InstanceType), item.ArrInstance.ArrConfig.Type);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, arrInstance.ExternalUrl ?? arrInstance.Url);
|
||||
|
||||
try
|
||||
{
|
||||
IArrClient arrClient = _arrClientFactory.GetClient(item.ArrInstance.ArrConfig.Type, arrInstance.Version);
|
||||
HashSet<SearchItem> searchItems = BuildSearchItems(item);
|
||||
|
||||
List<long> commandIds = await arrClient.SearchItemsAsync(arrInstance, searchItems);
|
||||
|
||||
Guid eventId = await _eventPublisher.PublishSearchTriggered(arrInstance.Name, 1, [item.Title], SeekerSearchType.Replacement);
|
||||
|
||||
if (!isDryRun)
|
||||
{
|
||||
await SaveCommandTrackersAsync(commandIds, eventId, arrInstance.Id, item.ArrInstance.ArrConfig.Type, item.ItemId, item.Title);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Replacement search triggered for '{Title}' on {InstanceName}",
|
||||
item.Title, arrInstance.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to process replacement search for '{Title}' on {InstanceName}",
|
||||
item.Title, arrInstance.Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!isDryRun)
|
||||
{
|
||||
_dataContext.SearchQueue.Remove(item);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static HashSet<SearchItem> BuildSearchItems(SearchQueueItem item)
|
||||
{
|
||||
if (item.SeriesId.HasValue && Enum.TryParse<SeriesSearchType>(item.SearchType, out var searchType))
|
||||
{
|
||||
return
|
||||
[
|
||||
new SeriesSearchItem
|
||||
{
|
||||
Id = item.ItemId,
|
||||
SeriesId = item.SeriesId.Value,
|
||||
SearchType = searchType
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return [new SearchItem { Id = item.ItemId }];
|
||||
}
|
||||
|
||||
private async Task ProcessProactiveSearchAsync(SeekerConfig config, bool isDryRun)
|
||||
{
|
||||
List<SeekerInstanceConfig> instanceConfigs = await _dataContext.SeekerInstanceConfigs
|
||||
.Include(s => s.ArrInstance)
|
||||
.ThenInclude(a => a.ArrConfig)
|
||||
.Where(s => s.Enabled && s.ArrInstance.Enabled)
|
||||
.ToListAsync();
|
||||
|
||||
instanceConfigs = instanceConfigs
|
||||
.Where(s => s.ArrInstance.ArrConfig.Type is InstanceType.Sonarr or InstanceType.Radarr)
|
||||
.ToList();
|
||||
|
||||
if (instanceConfigs.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No enabled Seeker instances found for proactive search");
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.UseRoundRobin)
|
||||
{
|
||||
// Round-robin: try instances in order of oldest LastProcessedAt,
|
||||
// stop after the first one that triggers a search.
|
||||
// This prevents cycle-complete-waiting instances from wasting a run.
|
||||
var ordered = instanceConfigs
|
||||
.OrderBy(s => s.LastProcessedAt ?? DateTime.MinValue)
|
||||
.ToList();
|
||||
|
||||
foreach (SeekerInstanceConfig instance in ordered)
|
||||
{
|
||||
bool searched = await ProcessSingleInstanceAsync(config, instance, isDryRun);
|
||||
|
||||
if (searched)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Process all enabled instances sequentially
|
||||
foreach (SeekerInstanceConfig instanceConfig in instanceConfigs)
|
||||
{
|
||||
await ProcessSingleInstanceAsync(config, instanceConfig, isDryRun);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessSingleInstanceAsync(SeekerConfig config, SeekerInstanceConfig instanceConfig, bool isDryRun)
|
||||
{
|
||||
ArrInstance arrInstance = instanceConfig.ArrInstance;
|
||||
InstanceType instanceType = arrInstance.ArrConfig.Type;
|
||||
|
||||
_logger.LogDebug("Processing {InstanceType} instance: {InstanceName}",
|
||||
instanceType, arrInstance.Name);
|
||||
|
||||
// Set context for event publishing
|
||||
ContextProvider.Set(nameof(InstanceType), instanceType);
|
||||
ContextProvider.Set(ContextProvider.Keys.ArrInstanceUrl, arrInstance.ExternalUrl ?? arrInstance.Url);
|
||||
|
||||
// Fetch queue once for both active download limit check and queue cross-referencing
|
||||
IArrClient arrClient = _arrClientFactory.GetClient(instanceType, arrInstance.Version);
|
||||
List<QueueRecord> queueRecords = [];
|
||||
|
||||
try
|
||||
{
|
||||
await _arrQueueIterator.Iterate(arrClient, arrInstance, records =>
|
||||
{
|
||||
queueRecords.AddRange(records);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch queue for {InstanceName}, proceeding without queue cross-referencing",
|
||||
arrInstance.Name);
|
||||
}
|
||||
|
||||
// Check active download limit using the fetched queue data
|
||||
if (instanceConfig.ActiveDownloadLimit > 0)
|
||||
{
|
||||
int activeDownloads = queueRecords.Count(r => r.SizeLeft > 0);
|
||||
if (activeDownloads >= instanceConfig.ActiveDownloadLimit)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Skipping proactive search for {InstanceName} — {Count} items actively downloading (limit: {Limit})",
|
||||
arrInstance.Name, activeDownloads, instanceConfig.ActiveDownloadLimit);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool searched = false;
|
||||
try
|
||||
{
|
||||
searched = await ProcessInstanceAsync(config, instanceConfig, arrInstance, instanceType, isDryRun, queueRecords);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to process {InstanceType} instance: {InstanceName}",
|
||||
instanceType, arrInstance.Name);
|
||||
}
|
||||
|
||||
// Update LastProcessedAt so round-robin moves on
|
||||
instanceConfig.LastProcessedAt = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
_dataContext.SeekerInstanceConfigs.Update(instanceConfig);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
return searched;
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessInstanceAsync(
|
||||
SeekerConfig config,
|
||||
SeekerInstanceConfig instanceConfig,
|
||||
ArrInstance arrInstance,
|
||||
InstanceType instanceType,
|
||||
bool isDryRun,
|
||||
List<QueueRecord> queueRecords)
|
||||
{
|
||||
// Load search history for the current cycle
|
||||
List<SeekerHistory> currentCycleHistory = await _dataContext.SeekerHistory
|
||||
.AsNoTracking()
|
||||
.Where(h => h.ArrInstanceId == arrInstance.Id && h.CycleId == instanceConfig.CurrentCycleId)
|
||||
.ToListAsync();
|
||||
|
||||
// Load all history for stale cleanup
|
||||
List<long> allHistoryExternalIds = await _dataContext.SeekerHistory
|
||||
.AsNoTracking()
|
||||
.Where(h => h.ArrInstanceId == arrInstance.Id)
|
||||
.Select(x => x.ExternalItemId)
|
||||
.ToListAsync();
|
||||
|
||||
// Derive item-level history for selection strategies
|
||||
Dictionary<long, DateTime> itemSearchHistory = currentCycleHistory
|
||||
.GroupBy(h => h.ExternalItemId)
|
||||
.ToDictionary(g => g.Key, g => g.Max(h => h.LastSearchedAt));
|
||||
|
||||
// Build queued-item lookups from active queue records
|
||||
var activeQueueRecords = queueRecords
|
||||
.Where(r => ActiveQueueStates.Contains(r.TrackedDownloadState))
|
||||
.ToList();
|
||||
|
||||
HashSet<SearchItem> searchItems;
|
||||
List<string> selectedNames;
|
||||
List<long> allLibraryIds;
|
||||
List<long> historyIds;
|
||||
int seasonNumber = 0;
|
||||
|
||||
if (instanceType == InstanceType.Radarr)
|
||||
{
|
||||
HashSet<long> queuedMovieIds = activeQueueRecords
|
||||
.Where(r => r.MovieId > 0)
|
||||
.Select(r => r.MovieId)
|
||||
.ToHashSet();
|
||||
|
||||
List<long> selectedIds;
|
||||
(selectedIds, selectedNames, allLibraryIds) = await ProcessRadarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, isDryRun, queuedMovieIds);
|
||||
searchItems = selectedIds.Select(id => new SearchItem { Id = id }).ToHashSet();
|
||||
historyIds = selectedIds;
|
||||
}
|
||||
else
|
||||
{
|
||||
HashSet<(long SeriesId, long SeasonNumber)> queuedSeasons = activeQueueRecords
|
||||
.Where(r => r.SeriesId > 0)
|
||||
.Select(r => (r.SeriesId, r.SeasonNumber))
|
||||
.ToHashSet();
|
||||
|
||||
(searchItems, selectedNames, allLibraryIds, historyIds, seasonNumber) =
|
||||
await ProcessSonarrAsync(config, arrInstance, instanceConfig, itemSearchHistory, currentCycleHistory, isDryRun, queuedSeasons: queuedSeasons);
|
||||
}
|
||||
|
||||
if (searchItems.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No items selected for search on {InstanceName}", arrInstance.Name);
|
||||
if (!isDryRun)
|
||||
{
|
||||
await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, allLibraryIds, allHistoryExternalIds);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Trigger search (arr client guards the HTTP request via dry run interceptor)
|
||||
IArrClient arrClient = _arrClientFactory.GetClient(instanceType, arrInstance.Version);
|
||||
List<long> commandIds = await arrClient.SearchItemsAsync(arrInstance, searchItems);
|
||||
|
||||
// Publish event (always saved, flagged with IsDryRun in EventPublisher)
|
||||
Guid eventId = await _eventPublisher.PublishSearchTriggered(arrInstance.Name, searchItems.Count, selectedNames, SeekerSearchType.Proactive, instanceConfig.CurrentCycleId);
|
||||
|
||||
_logger.LogInformation("Searched {Count} items on {InstanceName}: {Items}",
|
||||
searchItems.Count, arrInstance.Name, string.Join(", ", selectedNames));
|
||||
|
||||
// Update search history (always, so stats are accurate during dry run)
|
||||
await UpdateSearchHistoryAsync(arrInstance.Id, instanceType, instanceConfig.CurrentCycleId, historyIds, selectedNames, seasonNumber, isDryRun);
|
||||
|
||||
if (!isDryRun)
|
||||
{
|
||||
// Track commands
|
||||
long externalItemId = historyIds.FirstOrDefault();
|
||||
string itemTitle = selectedNames.FirstOrDefault() ?? string.Empty;
|
||||
await SaveCommandTrackersAsync(commandIds, eventId, arrInstance.Id, instanceType, externalItemId, itemTitle, seasonNumber);
|
||||
|
||||
// Cleanup stale history entries and old cycle history
|
||||
await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, allLibraryIds, allHistoryExternalIds);
|
||||
await CleanupOldCycleHistoryAsync(arrInstance, instanceConfig.CurrentCycleId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<(List<long> SelectedIds, List<string> SelectedNames, List<long> AllLibraryIds)> ProcessRadarrAsync(
|
||||
SeekerConfig config,
|
||||
ArrInstance arrInstance,
|
||||
SeekerInstanceConfig instanceConfig,
|
||||
Dictionary<long, DateTime> searchHistory,
|
||||
bool isDryRun,
|
||||
HashSet<long> queuedMovieIds)
|
||||
{
|
||||
List<SearchableMovie> movies = await _radarrClient.GetAllMoviesAsync(arrInstance);
|
||||
List<long> allLibraryIds = movies.Select(m => m.Id).ToList();
|
||||
|
||||
// Load cached CF scores when custom format score filtering is enabled
|
||||
Dictionary<long, CustomFormatScoreEntry>? cfScores = null;
|
||||
if (config.UseCustomFormatScore)
|
||||
{
|
||||
cfScores = await _dataContext.CustomFormatScoreEntries
|
||||
.AsNoTracking()
|
||||
.Where(e => e.ArrInstanceId == arrInstance.Id && e.ItemType == InstanceType.Radarr)
|
||||
.ToDictionaryAsync(e => e.ExternalItemId);
|
||||
}
|
||||
|
||||
// Apply filters — UseCutoff and UseCustomFormatScore are OR-ed: an item qualifies if it fails the quality cutoff OR the CF score cutoff.
|
||||
// Items without cutoff data or a cached CF score are excluded from the respective filter.
|
||||
DateTime graceCutoff = _timeProvider.GetUtcNow().UtcDateTime.AddHours(-config.PostReleaseGraceHours);
|
||||
var candidates = movies
|
||||
.Where(m => m.Status is "released")
|
||||
.Where(m => IsMoviePastGracePeriod(m, graceCutoff))
|
||||
.Where(m => !config.MonitoredOnly || m.Monitored)
|
||||
.Where(m => instanceConfig.SkipTags.Count == 0 || !m.Tags.Any(instanceConfig.SkipTags.Contains))
|
||||
.Where(m => !m.HasFile
|
||||
|| (!config.UseCutoff && !config.UseCustomFormatScore)
|
||||
|| (config.UseCutoff && (m.MovieFile?.QualityCutoffNotMet ?? false))
|
||||
|| (config.UseCustomFormatScore && cfScores != null && cfScores.TryGetValue(m.Id, out var entry) && entry.CurrentScore < entry.CutoffScore))
|
||||
.ToList();
|
||||
|
||||
instanceConfig.TotalEligibleItems = candidates.Count;
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return ([], [], allLibraryIds);
|
||||
}
|
||||
|
||||
// Exclude movies already in the download queue
|
||||
if (queuedMovieIds.Count > 0)
|
||||
{
|
||||
int beforeCount = candidates.Count;
|
||||
candidates = candidates
|
||||
.Where(m => !queuedMovieIds.Contains(m.Id))
|
||||
.ToList();
|
||||
|
||||
int skipped = beforeCount - candidates.Count;
|
||||
if (skipped > 0)
|
||||
{
|
||||
_logger.LogDebug("Excluded {Count} movies already in queue on {InstanceName}",
|
||||
skipped, arrInstance.Name);
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return ([], [], allLibraryIds);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cycle completion: all candidates already searched in current cycle
|
||||
bool cycleComplete = candidates.All(m => searchHistory.ContainsKey(m.Id));
|
||||
if (cycleComplete)
|
||||
{
|
||||
// Respect MinCycleTimeDays even when cycle completes due to queue filtering
|
||||
DateTime? cycleStartedAt = searchHistory.Count > 0 ? searchHistory.Values.Min() : null;
|
||||
if (ShouldWaitForMinCycleTime(instanceConfig, cycleStartedAt))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"skip | cycle complete but min time ({Days}) not elapsed (started {StartedAt}) | {InstanceName}",
|
||||
instanceConfig.MinCycleTimeDays, cycleStartedAt, arrInstance.Name);
|
||||
return ([], [], allLibraryIds);
|
||||
}
|
||||
|
||||
_logger.LogInformation("All {Count} items on {InstanceName} searched in current cycle, starting new cycle",
|
||||
candidates.Count, arrInstance.Name);
|
||||
|
||||
if (!isDryRun)
|
||||
{
|
||||
instanceConfig.CurrentCycleId = Guid.NewGuid();
|
||||
_dataContext.SeekerInstanceConfigs.Update(instanceConfig);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
searchHistory = new Dictionary<long, DateTime>();
|
||||
}
|
||||
|
||||
// Only pass unsearched items to the selector — already-searched items in this cycle are skipped
|
||||
var selectionCandidates = candidates
|
||||
.Where(m => !searchHistory.ContainsKey(m.Id))
|
||||
.Select(m => (m.Id, m.Added, LastSearched: (DateTime?)null))
|
||||
.ToList();
|
||||
|
||||
IItemSelector selector = ItemSelectorFactory.Create(config.SelectionStrategy);
|
||||
List<long> selectedIds = selector.Select(selectionCandidates, 1);
|
||||
|
||||
List<string> selectedNames = candidates
|
||||
.Where(m => selectedIds.Contains(m.Id))
|
||||
.Select(m => m.Title)
|
||||
.ToList();
|
||||
|
||||
foreach (long movieId in selectedIds)
|
||||
{
|
||||
SearchableMovie movie = candidates.First(m => m.Id == movieId);
|
||||
string reason = !movie.HasFile
|
||||
? "missing file"
|
||||
: config.UseCutoff && (movie.MovieFile?.QualityCutoffNotMet ?? false)
|
||||
? "does not meet quality cutoff"
|
||||
: "custom format score below cutoff";
|
||||
_logger.LogDebug("Selected '{Title}' for search on {InstanceName}: {Reason}",
|
||||
movie.Title, arrInstance.Name, reason);
|
||||
}
|
||||
|
||||
return (selectedIds, selectedNames, allLibraryIds);
|
||||
}
|
||||
|
||||
private async Task<(HashSet<SearchItem> SearchItems, List<string> SelectedNames, List<long> AllLibraryIds, List<long> HistoryIds, int SeasonNumber)> ProcessSonarrAsync(
|
||||
SeekerConfig config,
|
||||
ArrInstance arrInstance,
|
||||
SeekerInstanceConfig instanceConfig,
|
||||
Dictionary<long, DateTime> seriesSearchHistory,
|
||||
List<SeekerHistory> currentCycleHistory,
|
||||
bool isDryRun,
|
||||
bool isRetry = false,
|
||||
HashSet<(long SeriesId, long SeasonNumber)>? queuedSeasons = null)
|
||||
{
|
||||
List<SearchableSeries> series = await _sonarrClient.GetAllSeriesAsync(arrInstance);
|
||||
List<long> allLibraryIds = series.Select(s => s.Id).ToList();
|
||||
DateTime graceCutoff = _timeProvider.GetUtcNow().UtcDateTime.AddHours(-config.PostReleaseGraceHours);
|
||||
|
||||
// Apply filters
|
||||
var candidates = series
|
||||
.Where(s => s.Status is "continuing" or "ended" or "released")
|
||||
.Where(s => !config.MonitoredOnly || s.Monitored)
|
||||
.Where(s => instanceConfig.SkipTags.Count == 0 || !s.Tags.Any(instanceConfig.SkipTags.Contains))
|
||||
// Skip fully-downloaded series (unless quality upgrade filters active)
|
||||
.Where(s => config.UseCutoff || config.UseCustomFormatScore
|
||||
|| s.Statistics == null || s.Statistics.EpisodeCount == 0
|
||||
|| s.Statistics.EpisodeFileCount < s.Statistics.EpisodeCount)
|
||||
.ToList();
|
||||
|
||||
instanceConfig.TotalEligibleItems = candidates.Count;
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return ([], [], allLibraryIds, [], 0);
|
||||
}
|
||||
|
||||
// Pass all candidates — BuildSonarrSearchItemAsync handles season-level exclusion
|
||||
// LastSearched info helps the selector deprioritize recently-searched series
|
||||
var selectionCandidates = candidates
|
||||
.Select(s => (s.Id, s.Added, LastSearched: seriesSearchHistory.TryGetValue(s.Id, out var dt) ? (DateTime?)dt : null))
|
||||
.ToList();
|
||||
|
||||
// Select all candidates in priority order so the loop can find one with unsearched seasons
|
||||
IItemSelector selector = ItemSelectorFactory.Create(config.SelectionStrategy);
|
||||
List<long> candidateIds = selector.Select(selectionCandidates, selectionCandidates.Count);
|
||||
|
||||
// Drill down to find the first series with qualifying unsearched seasons
|
||||
foreach (long seriesId in candidateIds)
|
||||
{
|
||||
string seriesTitle = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
List<SeekerHistory> seriesHistory = currentCycleHistory
|
||||
.Where(h => h.ExternalItemId == seriesId)
|
||||
.ToList();
|
||||
|
||||
seriesTitle = candidates.First(s => s.Id == seriesId).Title;
|
||||
|
||||
(SeriesSearchItem? searchItem, SearchableEpisode? selectedEpisode) =
|
||||
await BuildSonarrSearchItemAsync(config, arrInstance, seriesId, seriesHistory, seriesTitle, graceCutoff, queuedSeasons);
|
||||
|
||||
if (searchItem is not null)
|
||||
{
|
||||
string displayName = $"{seriesTitle} S{searchItem.Id:D2}";
|
||||
int seasonNumber = (int)searchItem.Id;
|
||||
|
||||
return ([searchItem], [displayName], allLibraryIds, [seriesId], seasonNumber);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Skipping '{SeriesTitle}' — no qualifying seasons found", seriesTitle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to check episodes for '{SeriesTitle}', skipping", seriesTitle);
|
||||
}
|
||||
}
|
||||
|
||||
// All candidates were tried and none had qualifying unsearched seasons — cycle complete
|
||||
if (candidates.Count > 0 && !isRetry)
|
||||
{
|
||||
// Respect MinCycleTimeDays even when cycle completes due to queue filtering
|
||||
DateTime? cycleStartedAt = seriesSearchHistory.Count > 0 ? seriesSearchHistory.Values.Min() : null;
|
||||
if (ShouldWaitForMinCycleTime(instanceConfig, cycleStartedAt))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"skip | cycle complete but min time ({Days}) not elapsed (started {StartedAt}) | {InstanceName}",
|
||||
instanceConfig.MinCycleTimeDays, cycleStartedAt, arrInstance.Name);
|
||||
return ([], [], allLibraryIds, [], 0);
|
||||
}
|
||||
|
||||
_logger.LogInformation("All {Count} series on {InstanceName} searched in current cycle, starting new cycle",
|
||||
candidates.Count, arrInstance.Name);
|
||||
if (!isDryRun)
|
||||
{
|
||||
instanceConfig.CurrentCycleId = Guid.NewGuid();
|
||||
_dataContext.SeekerInstanceConfigs.Update(instanceConfig);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Retry with fresh cycle (only once to prevent infinite recursion)
|
||||
return await ProcessSonarrAsync(config, arrInstance, instanceConfig,
|
||||
new Dictionary<long, DateTime>(), [], isDryRun, isRetry: true, queuedSeasons: queuedSeasons);
|
||||
}
|
||||
|
||||
return ([], [], allLibraryIds, [], 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches episodes for a series and builds a season-level search item.
|
||||
/// Uses search history to prefer least-recently-searched seasons.
|
||||
/// </summary>
|
||||
private async Task<(SeriesSearchItem? SearchItem, SearchableEpisode? SelectedEpisode)> BuildSonarrSearchItemAsync(
|
||||
SeekerConfig config,
|
||||
ArrInstance arrInstance,
|
||||
long seriesId,
|
||||
List<SeekerHistory> seriesHistory,
|
||||
string seriesTitle,
|
||||
DateTime graceCutoff,
|
||||
HashSet<(long SeriesId, long SeasonNumber)>? queuedSeasons = null)
|
||||
{
|
||||
List<SearchableEpisode> episodes = await _sonarrClient.GetEpisodesAsync(arrInstance, seriesId);
|
||||
|
||||
// Fetch episode file metadata to determine cutoff status from the dedicated episodefile endpoint
|
||||
HashSet<long> cutoffNotMetFileIds = [];
|
||||
if (config.UseCutoff)
|
||||
{
|
||||
List<ArrEpisodeFile> episodeFiles = await _sonarrClient.GetEpisodeFilesAsync(arrInstance, seriesId);
|
||||
cutoffNotMetFileIds = episodeFiles
|
||||
.Where(f => f.QualityCutoffNotMet)
|
||||
.Select(f => f.Id)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
// Load cached CF scores for this series when custom format score filtering is enabled
|
||||
Dictionary<long, CustomFormatScoreEntry>? cfScores = null;
|
||||
if (config.UseCustomFormatScore)
|
||||
{
|
||||
cfScores = await _dataContext.CustomFormatScoreEntries
|
||||
.AsNoTracking()
|
||||
.Where(e => e.ArrInstanceId == arrInstance.Id
|
||||
&& e.ItemType == InstanceType.Sonarr
|
||||
&& e.ExternalItemId == seriesId)
|
||||
.ToDictionaryAsync(e => e.EpisodeId);
|
||||
}
|
||||
|
||||
// Filter to qualifying episodes — UseCutoff and UseCustomFormatScore are OR-ed.
|
||||
// Cutoff status comes from the episodefile endpoint; items without a cached CF score are excluded.
|
||||
var qualifying = episodes
|
||||
.Where(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value <= graceCutoff)
|
||||
.Where(e => !config.MonitoredOnly || e.Monitored)
|
||||
.Where(e => !e.HasFile
|
||||
|| (!config.UseCutoff && !config.UseCustomFormatScore)
|
||||
|| (config.UseCutoff && cutoffNotMetFileIds.Contains(e.EpisodeFileId))
|
||||
|| (config.UseCustomFormatScore && cfScores != null && cfScores.TryGetValue(e.Id, out var entry) && entry.CurrentScore < entry.CutoffScore))
|
||||
.OrderBy(e => e.SeasonNumber)
|
||||
.ThenBy(e => e.EpisodeNumber)
|
||||
.ToList();
|
||||
|
||||
if (qualifying.Count == 0)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
// Select least-recently-searched season using history
|
||||
var seasonGroups = qualifying
|
||||
.GroupBy(e => e.SeasonNumber)
|
||||
.Select(g =>
|
||||
{
|
||||
DateTime? lastSearched = seriesHistory
|
||||
.FirstOrDefault(h => h.SeasonNumber == g.Key)
|
||||
?.LastSearchedAt;
|
||||
return (SeasonNumber: g.Key, LastSearched: lastSearched, FirstEpisode: g.First());
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Find unsearched seasons first
|
||||
var unsearched = seasonGroups.Where(s => s.LastSearched is null).ToList();
|
||||
|
||||
// Exclude seasons already in the download queue
|
||||
if (queuedSeasons is { Count: > 0 })
|
||||
{
|
||||
int beforeCount = unsearched.Count;
|
||||
unsearched = unsearched
|
||||
.Where(s => !queuedSeasons.Contains((seriesId, (long)s.SeasonNumber)))
|
||||
.ToList();
|
||||
|
||||
int skipped = beforeCount - unsearched.Count;
|
||||
if (skipped > 0)
|
||||
{
|
||||
_logger.LogDebug("Excluded {Count} seasons already in queue for '{SeriesTitle}' on {InstanceName}",
|
||||
skipped, seriesTitle, arrInstance.Name);
|
||||
}
|
||||
}
|
||||
|
||||
if (unsearched.Count == 0)
|
||||
{
|
||||
// All unsearched seasons are either searched or in the queue
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
// Pick from unsearched seasons with some randomization
|
||||
var selected = unsearched
|
||||
.OrderBy(_ => Random.Shared.Next())
|
||||
.First();
|
||||
|
||||
// Log why this season was selected
|
||||
var seasonEpisodes = qualifying.Where(e => e.SeasonNumber == selected.SeasonNumber).ToList();
|
||||
int missingCount = seasonEpisodes.Count(e => !e.HasFile);
|
||||
int cutoffCount = seasonEpisodes.Count(e => e.HasFile && cutoffNotMetFileIds.Contains(e.EpisodeFileId));
|
||||
int cfCount = seasonEpisodes.Count(e => e.HasFile && cfScores != null
|
||||
&& cfScores.TryGetValue(e.Id, out var cfEntry) && cfEntry.CurrentScore < cfEntry.CutoffScore);
|
||||
|
||||
List<string> reasons = [];
|
||||
if (missingCount > 0)
|
||||
{
|
||||
reasons.Add($"{missingCount} missing");
|
||||
}
|
||||
|
||||
if (cutoffCount > 0)
|
||||
{
|
||||
reasons.Add($"{cutoffCount} cutoff unmet");
|
||||
}
|
||||
|
||||
if (cfCount > 0)
|
||||
{
|
||||
reasons.Add($"{cfCount} below CF score cutoff");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Selected '{SeriesTitle}' S{Season:D2} for search on {InstanceName}: {Reasons}",
|
||||
seriesTitle, selected.SeasonNumber, arrInstance.Name, string.Join(", ", reasons));
|
||||
|
||||
SeriesSearchItem searchItem = new()
|
||||
{
|
||||
Id = selected.SeasonNumber,
|
||||
SeriesId = seriesId,
|
||||
SearchType = SeriesSearchType.Season
|
||||
};
|
||||
|
||||
return (searchItem, selected.FirstEpisode);
|
||||
}
|
||||
|
||||
private async Task UpdateSearchHistoryAsync(
|
||||
Guid arrInstanceId,
|
||||
InstanceType instanceType,
|
||||
Guid cycleId,
|
||||
List<long> searchedIds,
|
||||
List<string>? itemTitles = null,
|
||||
int seasonNumber = 0,
|
||||
bool isDryRun = false)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
for (int i = 0; i < searchedIds.Count; i++)
|
||||
{
|
||||
long id = searchedIds[i];
|
||||
string title = itemTitles != null && i < itemTitles.Count ? itemTitles[i] : string.Empty;
|
||||
|
||||
SeekerHistory? existing = await _dataContext.SeekerHistory
|
||||
.FirstOrDefaultAsync(h =>
|
||||
h.ArrInstanceId == arrInstanceId
|
||||
&& h.ExternalItemId == id
|
||||
&& h.ItemType == instanceType
|
||||
&& h.SeasonNumber == seasonNumber
|
||||
&& h.CycleId == cycleId);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.LastSearchedAt = now;
|
||||
existing.SearchCount++;
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
existing.ItemTitle = title;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_dataContext.SeekerHistory.Add(new SeekerHistory
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
ExternalItemId = id,
|
||||
ItemType = instanceType,
|
||||
SeasonNumber = seasonNumber,
|
||||
CycleId = cycleId,
|
||||
LastSearchedAt = now,
|
||||
ItemTitle = title,
|
||||
IsDryRun = isDryRun,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SaveCommandTrackersAsync(
|
||||
List<long> commandIds,
|
||||
Guid eventId,
|
||||
Guid arrInstanceId,
|
||||
InstanceType instanceType,
|
||||
long externalItemId,
|
||||
string itemTitle,
|
||||
int seasonNumber = 0)
|
||||
{
|
||||
if (commandIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (long commandId in commandIds)
|
||||
{
|
||||
_dataContext.SeekerCommandTrackers.Add(new SeekerCommandTracker
|
||||
{
|
||||
ArrInstanceId = arrInstanceId,
|
||||
CommandId = commandId,
|
||||
EventId = eventId,
|
||||
ExternalItemId = externalItemId,
|
||||
ItemTitle = itemTitle,
|
||||
ItemType = instanceType,
|
||||
SeasonNumber = seasonNumber,
|
||||
});
|
||||
}
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task CleanupStaleHistoryAsync(
|
||||
Guid arrInstanceId,
|
||||
InstanceType instanceType,
|
||||
List<long> currentLibraryIds,
|
||||
IEnumerable<long> historyExternalIds)
|
||||
{
|
||||
// Find history entries for items no longer in the library
|
||||
var staleIds = historyExternalIds
|
||||
.Except(currentLibraryIds)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (staleIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _dataContext.SeekerHistory
|
||||
.Where(h => h.ArrInstanceId == arrInstanceId
|
||||
&& h.ItemType == instanceType
|
||||
&& staleIds.Contains(h.ExternalItemId))
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Cleaned up {Count} stale Seeker history entries for instance {InstanceId}",
|
||||
staleIds.Count,
|
||||
arrInstanceId
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes history entries from previous cycles that are older than 30 days.
|
||||
/// Recent cycle history is retained for statistics and history viewing.
|
||||
/// </summary>
|
||||
private async Task CleanupOldCycleHistoryAsync(ArrInstance arrInstance, Guid currentCycleId)
|
||||
{
|
||||
DateTime cutoff = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-30);
|
||||
|
||||
int deleted = await _dataContext.SeekerHistory
|
||||
.Where(h => h.ArrInstanceId == arrInstance.Id
|
||||
&& h.CycleId != currentCycleId
|
||||
&& h.LastSearchedAt < cutoff)
|
||||
.ExecuteDeleteAsync();
|
||||
|
||||
if (deleted > 0)
|
||||
{
|
||||
_logger.LogDebug("Cleaned up {Count} old cycle history entries (>30 days) for instance {InstanceName}",
|
||||
deleted, arrInstance.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the minimum cycle time constraint prevents starting a new cycle.
|
||||
/// Returns true if the cycle started recently and MinCycleTimeDays has not yet elapsed.
|
||||
/// </summary>
|
||||
private bool ShouldWaitForMinCycleTime(SeekerInstanceConfig instanceConfig, DateTime? cycleStartedAt)
|
||||
{
|
||||
if (cycleStartedAt is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var elapsed = _timeProvider.GetUtcNow().UtcDateTime - cycleStartedAt.Value;
|
||||
return elapsed.TotalDays < instanceConfig.MinCycleTimeDays;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the movie's release date is past the grace period cutoff.
|
||||
/// Movies without any release date info are treated as released.
|
||||
/// </summary>
|
||||
private static bool IsMoviePastGracePeriod(SearchableMovie movie, DateTime graceCutoff)
|
||||
{
|
||||
DateTime? releaseDate = movie.DigitalRelease ?? movie.PhysicalRelease ?? movie.InCinemas;
|
||||
return releaseDate is null || releaseDate.Value <= graceCutoff;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using Cleanuparr.Domain.Entities.Arr;
|
||||
using Cleanuparr.Domain.Entities.Arr.Queue;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that polls arr command status for pending search commands
|
||||
/// and inspects the download queue for grabbed items after completion.
|
||||
/// </summary>
|
||||
public class SeekerCommandMonitor : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan IdleInterval = TimeSpan.FromSeconds(60);
|
||||
private static readonly TimeSpan CommandTimeout = TimeSpan.FromMinutes(10);
|
||||
|
||||
private readonly ILogger<SeekerCommandMonitor> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SeekerCommandMonitor(
|
||||
ILogger<SeekerCommandMonitor> logger,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_scopeFactory = scopeFactory;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Wait for app startup
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
bool hadWork = await ProcessPendingCommandsAsync(stoppingToken);
|
||||
await Task.Delay(hadWork ? PollInterval : IdleInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in SeekerCommandMonitor");
|
||||
await Task.Delay(IdleInterval, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ProcessPendingCommandsAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using IServiceScope scope = _scopeFactory.CreateScope();
|
||||
var dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
|
||||
var arrClientFactory = scope.ServiceProvider.GetRequiredService<IArrClientFactory>();
|
||||
var eventPublisher = scope.ServiceProvider.GetRequiredService<IEventPublisher>();
|
||||
|
||||
List<SeekerCommandTracker> pendingTrackers = await dataContext.SeekerCommandTrackers
|
||||
.Include(t => t.ArrInstance)
|
||||
.ThenInclude(a => a.ArrConfig)
|
||||
.Where(t => t.Status != SearchCommandStatus.Completed
|
||||
&& t.Status != SearchCommandStatus.Failed
|
||||
&& t.Status != SearchCommandStatus.TimedOut)
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
if (pendingTrackers.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle timed-out commands
|
||||
var timedOut = pendingTrackers
|
||||
.Where(t => _timeProvider.GetUtcNow().UtcDateTime - t.CreatedAt > CommandTimeout)
|
||||
.ToList();
|
||||
|
||||
foreach (var tracker in timedOut)
|
||||
{
|
||||
_logger.LogWarning("Search command {CommandId} timed out for '{Title}' on {Instance}",
|
||||
tracker.CommandId, tracker.ItemTitle, tracker.ArrInstance.Name);
|
||||
tracker.Status = SearchCommandStatus.TimedOut;
|
||||
}
|
||||
|
||||
// Group remaining by event ID for batch processing
|
||||
var activeTrackers = pendingTrackers.Except(timedOut).ToList();
|
||||
var trackersByInstance = activeTrackers.GroupBy(t => t.ArrInstanceId);
|
||||
|
||||
foreach (var instanceGroup in trackersByInstance)
|
||||
{
|
||||
var arrInstance = instanceGroup.First().ArrInstance;
|
||||
IArrClient arrClient = arrClientFactory.GetClient(arrInstance.ArrConfig.Type, arrInstance.Version);
|
||||
|
||||
foreach (var tracker in instanceGroup)
|
||||
{
|
||||
try
|
||||
{
|
||||
ArrCommandStatus status = await arrClient.GetCommandStatusAsync(arrInstance, tracker.CommandId);
|
||||
UpdateTrackerStatus(tracker, status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to check command {CommandId} status on {Instance}",
|
||||
tracker.CommandId, arrInstance.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await dataContext.SaveChangesAsync(stoppingToken);
|
||||
|
||||
// Process completed/failed events
|
||||
var allTrackers = await dataContext.SeekerCommandTrackers
|
||||
.Include(t => t.ArrInstance)
|
||||
.ThenInclude(a => a.ArrConfig)
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
var trackersByEvent = allTrackers.GroupBy(t => t.EventId);
|
||||
|
||||
foreach (var eventGroup in trackersByEvent)
|
||||
{
|
||||
Guid eventId = eventGroup.Key;
|
||||
var trackers = eventGroup.ToList();
|
||||
|
||||
bool allTerminal = trackers.All(t =>
|
||||
t.Status is SearchCommandStatus.Completed
|
||||
or SearchCommandStatus.Failed
|
||||
or SearchCommandStatus.TimedOut);
|
||||
|
||||
if (!allTerminal)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool anyFailed = trackers.Any(t => t.Status is SearchCommandStatus.Failed or SearchCommandStatus.TimedOut);
|
||||
|
||||
if (anyFailed)
|
||||
{
|
||||
await eventPublisher.PublishSearchCompleted(eventId, SearchCommandStatus.Failed);
|
||||
_logger.LogWarning("Search command(s) failed for event {EventId}", eventId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// All completed — inspect download queue for grabbed items
|
||||
object? resultData = await InspectDownloadQueueAsync(trackers, arrClientFactory);
|
||||
await eventPublisher.PublishSearchCompleted(eventId, SearchCommandStatus.Completed, resultData);
|
||||
_logger.LogDebug("Search command(s) completed for event {EventId}", eventId);
|
||||
}
|
||||
|
||||
// Remove processed trackers
|
||||
dataContext.SeekerCommandTrackers.RemoveRange(trackers);
|
||||
}
|
||||
|
||||
await dataContext.SaveChangesAsync(stoppingToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void UpdateTrackerStatus(SeekerCommandTracker tracker, ArrCommandStatus commandStatus)
|
||||
{
|
||||
tracker.Status = commandStatus.Status.ToLowerInvariant() switch
|
||||
{
|
||||
"completed" => SearchCommandStatus.Completed,
|
||||
"failed" => SearchCommandStatus.Failed,
|
||||
"started" => SearchCommandStatus.Started,
|
||||
_ => tracker.Status // Keep current status for queued/other states
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<object?> InspectDownloadQueueAsync(
|
||||
List<SeekerCommandTracker> trackers,
|
||||
IArrClientFactory arrClientFactory)
|
||||
{
|
||||
var allGrabbedItems = new List<object>();
|
||||
|
||||
// Group by instance to inspect each instance's queue separately
|
||||
foreach (var instanceGroup in trackers.GroupBy(t => t.ArrInstanceId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var tracker = instanceGroup.First();
|
||||
var arrInstance = tracker.ArrInstance;
|
||||
IArrClient arrClient = arrClientFactory.GetClient(arrInstance.ArrConfig.Type, arrInstance.Version);
|
||||
|
||||
// Fetch the first page of the queue
|
||||
QueueListResponse queue = await arrClient.GetQueueItemsAsync(arrInstance, 1);
|
||||
|
||||
// Find records matching any tracker in this instance group
|
||||
foreach (var t in instanceGroup)
|
||||
{
|
||||
var grabbedItems = queue.Records
|
||||
.Where(r => t.ItemType == InstanceType.Radarr
|
||||
? r.MovieId == t.ExternalItemId
|
||||
: r.SeriesId == t.ExternalItemId
|
||||
&& (t.SeasonNumber == 0 || r.SeasonNumber == t.SeasonNumber))
|
||||
.Select(r => new
|
||||
{
|
||||
r.Title,
|
||||
r.Status,
|
||||
r.Protocol,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (grabbedItems.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Search for '{Title}' on {Instance} grabbed {Count} items: {Items}",
|
||||
t.ItemTitle, arrInstance.Name, grabbedItems.Count,
|
||||
string.Join(", ", grabbedItems.Select(g => g.Title)));
|
||||
|
||||
allGrabbedItems.AddRange(grabbedItems);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to inspect download queue after search completion");
|
||||
}
|
||||
}
|
||||
|
||||
return allGrabbedItems.Count > 0 ? new { GrabbedItems = allGrabbedItems } : null;
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,6 @@ public interface INotificationPublisher
|
||||
Task NotifyDownloadCleaned(double ratio, TimeSpan seedingTime, string categoryName, CleanReason reason);
|
||||
|
||||
Task NotifyCategoryChanged(string oldCategory, string newCategory, bool isTag = false);
|
||||
|
||||
Task NotifySearchTriggered(string instanceName, int itemCount, IEnumerable<string> items);
|
||||
}
|
||||
@@ -13,4 +13,6 @@ public sealed record NotificationEventFlags
|
||||
public bool OnDownloadCleaned { get; init; }
|
||||
|
||||
public bool OnCategoryChanged { get; init; }
|
||||
|
||||
public bool OnSearchTriggered { get; init; }
|
||||
}
|
||||
|
||||
@@ -131,7 +131,8 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
|
||||
OnSlowStrike = config.OnSlowStrike,
|
||||
OnQueueItemDeleted = config.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = config.OnDownloadCleaned,
|
||||
OnCategoryChanged = config.OnCategoryChanged
|
||||
OnCategoryChanged = config.OnCategoryChanged,
|
||||
OnSearchTriggered = config.OnSearchTriggered
|
||||
};
|
||||
|
||||
var configuration = config.Type switch
|
||||
@@ -167,6 +168,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
|
||||
NotificationEventType.QueueItemDeleted => events.OnQueueItemDeleted,
|
||||
NotificationEventType.DownloadCleaned => events.OnDownloadCleaned,
|
||||
NotificationEventType.CategoryChanged => events.OnCategoryChanged,
|
||||
NotificationEventType.SearchTriggered => events.OnSearchTriggered,
|
||||
NotificationEventType.Test => true,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(eventType), $"Provider type {eventType} is not yet registered")
|
||||
};
|
||||
|
||||
@@ -82,6 +82,19 @@ public class NotificationPublisher : INotificationPublisher
|
||||
}
|
||||
}
|
||||
|
||||
public virtual async Task NotifySearchTriggered(string instanceName, int itemCount, IEnumerable<string> items)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = BuildSearchTriggeredContext(instanceName, itemCount, items);
|
||||
await SendNotificationAsync(NotificationEventType.SearchTriggered, context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to notify search triggered");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendNotificationAsync(NotificationEventType eventType, NotificationContext context)
|
||||
{
|
||||
await _dryRunInterceptor.InterceptAsync(SendNotificationInternalAsync, (eventType, context));
|
||||
@@ -229,6 +242,29 @@ public class NotificationPublisher : INotificationPublisher
|
||||
return context;
|
||||
}
|
||||
|
||||
private static NotificationContext BuildSearchTriggeredContext(string instanceName, int itemCount, IEnumerable<string> items)
|
||||
{
|
||||
var instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
|
||||
var instanceUrl = ContextProvider.Get<Uri>(ContextProvider.Keys.ArrInstanceUrl);
|
||||
var itemList = items as string[] ?? items.ToArray();
|
||||
var itemsDisplay = string.Join(", ", itemList.Take(5)) + (itemList.Length > 5 ? $" (+{itemList.Length - 5} more)" : "");
|
||||
|
||||
return new NotificationContext
|
||||
{
|
||||
EventType = NotificationEventType.SearchTriggered,
|
||||
Title = "Search triggered",
|
||||
Description = $"Searched {itemCount} items on {instanceName}",
|
||||
Severity = EventSeverity.Information,
|
||||
Data = new Dictionary<string, string>
|
||||
{
|
||||
["Instance type"] = instanceType.ToString(),
|
||||
["Url"] = instanceUrl.ToString(),
|
||||
["Item count"] = itemCount.ToString(),
|
||||
["Items"] = itemsDisplay,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationEventType MapStrikeTypeToEventType(StrikeType strikeType)
|
||||
{
|
||||
return strikeType switch
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Seeker;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for selecting items to search based on a strategy
|
||||
/// </summary>
|
||||
public interface IItemSelector
|
||||
{
|
||||
/// <summary>
|
||||
/// Selects up to <paramref name="count"/> item IDs from the candidates
|
||||
/// </summary>
|
||||
/// <param name="candidates">List of (id, dateAdded, lastSearched) tuples</param>
|
||||
/// <param name="count">Maximum number of items to select</param>
|
||||
/// <returns>Selected item IDs</returns>
|
||||
List<long> Select(List<(long Id, DateTime? Added, DateTime? LastSearched)> candidates, int count);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Seeker;
|
||||
|
||||
/// <summary>
|
||||
/// Factory that returns the appropriate item selector based on the configured strategy
|
||||
/// </summary>
|
||||
public static class ItemSelectorFactory
|
||||
{
|
||||
public static IItemSelector Create(SelectionStrategy strategy)
|
||||
{
|
||||
return strategy switch
|
||||
{
|
||||
SelectionStrategy.OldestSearchFirst => new OldestSearchFirstSelector(),
|
||||
SelectionStrategy.OldestSearchWeighted => new OldestSearchWeightedSelector(),
|
||||
SelectionStrategy.NewestFirst => new NewestFirstSelector(),
|
||||
SelectionStrategy.NewestWeighted => new NewestWeightedSelector(),
|
||||
SelectionStrategy.BalancedWeighted => new BalancedWeightedSelector(),
|
||||
SelectionStrategy.Random => new RandomSelector(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(strategy), strategy, "Unknown selection strategy")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
|
||||
/// <summary>
|
||||
/// Selects items using weighted random sampling that combines search recency and add date.
|
||||
/// Each item is ranked on both dimensions, and the average rank determines its weight.
|
||||
/// Items that are both recently added and haven't been searched get the highest combined weight.
|
||||
/// </summary>
|
||||
public sealed class BalancedWeightedSelector : IItemSelector
|
||||
{
|
||||
public List<long> Select(List<(long Id, DateTime? Added, DateTime? LastSearched)> candidates, int count)
|
||||
{
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
count = Math.Min(count, candidates.Count);
|
||||
int n = candidates.Count;
|
||||
|
||||
// Rank by search recency: never-searched / oldest-searched first (ascending)
|
||||
var searchRanks = candidates
|
||||
.OrderBy(c => c.LastSearched ?? DateTime.MinValue)
|
||||
.Select((c, index) => (c.Id, Rank: n - index))
|
||||
.ToDictionary(x => x.Id, x => x.Rank);
|
||||
|
||||
// Rank by add date: newest first (descending)
|
||||
var ageRanks = candidates
|
||||
.OrderByDescending(c => c.Added ?? DateTime.MinValue)
|
||||
.Select((c, index) => (c.Id, Rank: n - index))
|
||||
.ToDictionary(x => x.Id, x => x.Rank);
|
||||
|
||||
// Composite weight = average of both ranks (higher = more likely to be selected)
|
||||
var selected = new List<long>(count);
|
||||
var pool = candidates
|
||||
.Select(c => (c.Id, Weight: (searchRanks[c.Id] + ageRanks[c.Id]) / 2.0))
|
||||
.ToList();
|
||||
|
||||
for (int i = 0; i < count && pool.Count > 0; i++)
|
||||
{
|
||||
double totalWeight = 0;
|
||||
for (int j = 0; j < pool.Count; j++)
|
||||
{
|
||||
totalWeight += pool[j].Weight;
|
||||
}
|
||||
|
||||
double target = Random.Shared.NextDouble() * totalWeight;
|
||||
double cumulative = 0;
|
||||
int selectedIndex = pool.Count - 1;
|
||||
|
||||
for (int j = 0; j < pool.Count; j++)
|
||||
{
|
||||
cumulative += pool[j].Weight;
|
||||
if (cumulative >= target)
|
||||
{
|
||||
selectedIndex = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
selected.Add(pool[selectedIndex].Id);
|
||||
pool.RemoveAt(selectedIndex);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
|
||||
/// <summary>
|
||||
/// Selects items by date added, newest first.
|
||||
/// Good for quickly finding newly added content.
|
||||
/// </summary>
|
||||
public sealed class NewestFirstSelector : IItemSelector
|
||||
{
|
||||
public List<long> Select(List<(long Id, DateTime? Added, DateTime? LastSearched)> candidates, int count)
|
||||
{
|
||||
return candidates
|
||||
.OrderByDescending(c => c.Added ?? DateTime.MinValue)
|
||||
.Take(count)
|
||||
.Select(c => c.Id)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
|
||||
/// <summary>
|
||||
/// Selects items using rank-based weighted random sampling on add date.
|
||||
/// Recently added items are ranked higher and more likely to be selected.
|
||||
/// Good for users who regularly add new content and want it searched quickly.
|
||||
/// </summary>
|
||||
public sealed class NewestWeightedSelector : IItemSelector
|
||||
{
|
||||
public List<long> Select(List<(long Id, DateTime? Added, DateTime? LastSearched)> candidates, int count)
|
||||
{
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
count = Math.Min(count, candidates.Count);
|
||||
|
||||
// Sort by Added descending
|
||||
var ranked = candidates
|
||||
.OrderByDescending(c => c.Added ?? DateTime.MinValue)
|
||||
.ToList();
|
||||
|
||||
return OldestSearchWeightedSelector.WeightedRandomByRank(ranked, count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
|
||||
/// <summary>
|
||||
/// Selects items that haven't been searched the longest.
|
||||
/// Items never searched are prioritized first.
|
||||
/// Provides systematic coverage of the entire library.
|
||||
/// </summary>
|
||||
public sealed class OldestSearchFirstSelector : IItemSelector
|
||||
{
|
||||
public List<long> Select(List<(long Id, DateTime? Added, DateTime? LastSearched)> candidates, int count)
|
||||
{
|
||||
return candidates
|
||||
.OrderBy(c => c.LastSearched ?? DateTime.MinValue)
|
||||
.Take(count)
|
||||
.Select(c => c.Id)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
|
||||
/// <summary>
|
||||
/// Selects items using rank-based weighted random sampling on search recency.
|
||||
/// Items that haven't been searched recently are ranked higher and more likely to be selected.
|
||||
/// Never-searched items receive the highest rank.
|
||||
/// </summary>
|
||||
public sealed class OldestSearchWeightedSelector : IItemSelector
|
||||
{
|
||||
public List<long> Select(List<(long Id, DateTime? Added, DateTime? LastSearched)> candidates, int count)
|
||||
{
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
count = Math.Min(count, candidates.Count);
|
||||
|
||||
// Sort by LastSearched ascending, then oldest searches, so rank 0 = highest priority
|
||||
var ranked = candidates
|
||||
.OrderBy(c => c.LastSearched ?? DateTime.MinValue)
|
||||
.ToList();
|
||||
|
||||
return WeightedRandomByRank(ranked, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs weighted random selection without replacement using rank-based weights.
|
||||
/// The item at rank 0 gets the highest weight (N), rank 1 gets (N-1), etc.
|
||||
/// </summary>
|
||||
internal static List<long> WeightedRandomByRank(
|
||||
List<(long Id, DateTime? Added, DateTime? LastSearched)> ranked,
|
||||
int count)
|
||||
{
|
||||
int n = ranked.Count;
|
||||
var selected = new List<long>(count);
|
||||
|
||||
// Build initial weights from rank: highest rank (index 0) gets weight N, lowest gets 1
|
||||
var pool = new List<(long Id, double Weight)>(n);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
pool.Add((ranked[i].Id, n - i));
|
||||
}
|
||||
|
||||
for (int i = 0; i < count && pool.Count > 0; i++)
|
||||
{
|
||||
double totalWeight = 0;
|
||||
for (int j = 0; j < pool.Count; j++)
|
||||
{
|
||||
totalWeight += pool[j].Weight;
|
||||
}
|
||||
|
||||
double target = Random.Shared.NextDouble() * totalWeight;
|
||||
double cumulative = 0;
|
||||
int selectedIndex = pool.Count - 1;
|
||||
|
||||
for (int j = 0; j < pool.Count; j++)
|
||||
{
|
||||
cumulative += pool[j].Weight;
|
||||
if (cumulative >= target)
|
||||
{
|
||||
selectedIndex = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
selected.Add(pool[selectedIndex].Id);
|
||||
pool.RemoveAt(selectedIndex);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Seeker.Selectors;
|
||||
|
||||
/// <summary>
|
||||
/// Selects items randomly using Fisher-Yates shuffle.
|
||||
/// Simplest strategy with no bias.
|
||||
/// </summary>
|
||||
public sealed class RandomSelector : IItemSelector
|
||||
{
|
||||
public List<long> Select(List<(long Id, DateTime? Added, DateTime? LastSearched)> candidates, int count)
|
||||
{
|
||||
count = Math.Min(count, candidates.Count);
|
||||
var shuffled = new List<(long Id, DateTime? Added, DateTime? LastSearched)>(candidates);
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
int j = Random.Shared.Next(i, shuffled.Count);
|
||||
(shuffled[i], shuffled[j]) = (shuffled[j], shuffled[i]);
|
||||
}
|
||||
|
||||
return shuffled
|
||||
.Take(count)
|
||||
.Select(c => c.Id)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,11 @@ public static class CronValidationHelper
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of maximum {Constants.TriggerMaxLimit.TotalHours} hours");
|
||||
}
|
||||
|
||||
if (jobType is JobType.Seeker && triggerValue < Constants.SeekerMinLimit)
|
||||
{
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.SeekerMinLimit.TotalMinutes} minutes");
|
||||
}
|
||||
|
||||
if (jobType is not JobType.MalwareBlocker && triggerValue < Constants.TriggerMinLimit)
|
||||
{
|
||||
throw new ValidationException($"{cronExpression} should have a fire time of minimum {Constants.TriggerMinLimit.TotalSeconds} seconds");
|
||||
@@ -65,7 +70,7 @@ public static class CronValidationHelper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new ValidationException($"Error validating cron expression '{cronExpression}': {ex.Message}");
|
||||
throw new ValidationException($"Error validating cron expression '{cronExpression}'", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using Cleanuparr.Persistence.Models.Configuration.General;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
@@ -18,30 +17,6 @@ public sealed class GeneralConfigTests
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithMinimumSearchDelay_DoesNotThrow()
|
||||
{
|
||||
var config = new GeneralConfig
|
||||
{
|
||||
HttpTimeout = 100,
|
||||
SearchDelay = (ushort)Constants.MinSearchDelaySeconds
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithAboveMinimumSearchDelay_DoesNotThrow()
|
||||
{
|
||||
var config = new GeneralConfig
|
||||
{
|
||||
HttpTimeout = 100,
|
||||
SearchDelay = 300
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - HttpTimeout Validation
|
||||
@@ -52,7 +27,6 @@ public sealed class GeneralConfigTests
|
||||
var config = new GeneralConfig
|
||||
{
|
||||
HttpTimeout = 0,
|
||||
SearchDelay = (ushort)Constants.MinSearchDelaySeconds
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
@@ -69,7 +43,6 @@ public sealed class GeneralConfigTests
|
||||
var config = new GeneralConfig
|
||||
{
|
||||
HttpTimeout = httpTimeout,
|
||||
SearchDelay = (ushort)Constants.MinSearchDelaySeconds
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
@@ -77,52 +50,6 @@ public sealed class GeneralConfigTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - SearchDelay Validation
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithBelowMinimumSearchDelay_ThrowsValidationException()
|
||||
{
|
||||
var config = new GeneralConfig
|
||||
{
|
||||
HttpTimeout = 100,
|
||||
SearchDelay = (ushort)(Constants.MinSearchDelaySeconds - 1)
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe($"SearchDelay must be at least {Constants.MinSearchDelaySeconds} seconds");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithZeroSearchDelay_ThrowsValidationException()
|
||||
{
|
||||
var config = new GeneralConfig
|
||||
{
|
||||
HttpTimeout = 100,
|
||||
SearchDelay = 0
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe($"SearchDelay must be at least {Constants.MinSearchDelaySeconds} seconds");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((ushort)1)]
|
||||
[InlineData((ushort)30)]
|
||||
[InlineData((ushort)59)]
|
||||
public void Validate_WithVariousBelowMinimumSearchDelay_ThrowsValidationException(ushort searchDelay)
|
||||
{
|
||||
var config = new GeneralConfig
|
||||
{
|
||||
HttpTimeout = 100,
|
||||
SearchDelay = searchDelay
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe($"SearchDelay must be at least {Constants.MinSearchDelaySeconds} seconds");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - Calls LoggingConfig.Validate
|
||||
|
||||
[Fact]
|
||||
@@ -131,7 +58,6 @@ public sealed class GeneralConfigTests
|
||||
var config = new GeneralConfig
|
||||
{
|
||||
HttpTimeout = 100,
|
||||
SearchDelay = (ushort)Constants.MinSearchDelaySeconds,
|
||||
Log = new LoggingConfig
|
||||
{
|
||||
RollingSizeMB = 101 // Exceeds max of 100
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Tests.Models.Configuration.Seeker;
|
||||
|
||||
public sealed class SeekerConfigTests
|
||||
{
|
||||
#region Validate - Valid Configurations
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithDefaultConfig_DoesNotThrow()
|
||||
{
|
||||
var config = new SeekerConfig();
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((ushort)2)]
|
||||
[InlineData((ushort)3)]
|
||||
[InlineData((ushort)4)]
|
||||
[InlineData((ushort)5)]
|
||||
[InlineData((ushort)6)]
|
||||
[InlineData((ushort)10)]
|
||||
[InlineData((ushort)12)]
|
||||
[InlineData((ushort)15)]
|
||||
[InlineData((ushort)20)]
|
||||
[InlineData((ushort)30)]
|
||||
public void Validate_WithValidIntervals_DoesNotThrow(ushort interval)
|
||||
{
|
||||
var config = new SeekerConfig { SearchInterval = interval };
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate - Invalid Configurations
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithIntervalBelowMinimum_ThrowsValidationException()
|
||||
{
|
||||
var config = new SeekerConfig { SearchInterval = 1 };
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldContain("at least");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithIntervalAboveMaximum_ThrowsValidationException()
|
||||
{
|
||||
var config = new SeekerConfig { SearchInterval = 31 };
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldContain("at most");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((ushort)7)]
|
||||
[InlineData((ushort)8)]
|
||||
[InlineData((ushort)9)]
|
||||
[InlineData((ushort)11)]
|
||||
[InlineData((ushort)13)]
|
||||
[InlineData((ushort)14)]
|
||||
public void Validate_WithNonDivisorInterval_ThrowsValidationException(ushort interval)
|
||||
{
|
||||
var config = new SeekerConfig { SearchInterval = interval };
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldContain("Invalid search interval");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToCronExpression
|
||||
|
||||
[Theory]
|
||||
[InlineData((ushort)2, "0 */2 * * * ?")]
|
||||
[InlineData((ushort)5, "0 */5 * * * ?")]
|
||||
[InlineData((ushort)10, "0 */10 * * * ?")]
|
||||
[InlineData((ushort)30, "0 */30 * * * ?")]
|
||||
public void ToCronExpression_ReturnsCorrectCron(ushort interval, string expectedCron)
|
||||
{
|
||||
var config = new SeekerConfig { SearchInterval = interval };
|
||||
|
||||
config.ToCronExpression().ShouldBe(expectedCron);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using Cleanuparr.Persistence.Models.Configuration.BlacklistSync;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Seeker;
|
||||
using Cleanuparr.Persistence.Models.State;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -65,6 +66,20 @@ public class DataContext : DbContext
|
||||
|
||||
public DbSet<BlacklistSyncConfig> BlacklistSyncConfigs { get; set; }
|
||||
|
||||
public DbSet<SeekerConfig> SeekerConfigs { get; set; }
|
||||
|
||||
public DbSet<SeekerInstanceConfig> SeekerInstanceConfigs { get; set; }
|
||||
|
||||
public DbSet<SeekerHistory> SeekerHistory { get; set; }
|
||||
|
||||
public DbSet<SearchQueueItem> SearchQueue { get; set; }
|
||||
|
||||
public DbSet<CustomFormatScoreEntry> CustomFormatScoreEntries { get; set; }
|
||||
|
||||
public DbSet<CustomFormatScoreHistory> CustomFormatScoreHistory { get; set; }
|
||||
|
||||
public DbSet<SeekerCommandTracker> SeekerCommandTrackers { get; set; }
|
||||
|
||||
public DataContext()
|
||||
{
|
||||
}
|
||||
@@ -181,6 +196,9 @@ public class DataContext : DbContext
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasIndex(p => p.Name).IsUnique();
|
||||
|
||||
entity.Property(e => e.CreatedAt).HasConversion(new UtcDateTimeConverter());
|
||||
entity.Property(e => e.UpdatedAt).HasConversion(new UtcDateTimeConverter());
|
||||
});
|
||||
|
||||
// Configure PushoverConfig List<string> conversions
|
||||
@@ -197,6 +215,75 @@ public class DataContext : DbContext
|
||||
v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList());
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SeekerInstanceConfig>(entity =>
|
||||
{
|
||||
entity.HasOne(s => s.ArrInstance)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.ArrInstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasIndex(s => s.ArrInstanceId).IsUnique();
|
||||
|
||||
entity.Property(s => s.LastProcessedAt).HasConversion(new UtcDateTimeConverter());
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SeekerHistory>(entity =>
|
||||
{
|
||||
entity.HasOne(s => s.ArrInstance)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.ArrInstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasIndex(s => new { s.ArrInstanceId, s.ExternalItemId, s.ItemType, s.SeasonNumber, s.CycleId }).IsUnique();
|
||||
|
||||
entity.Property(s => s.LastSearchedAt).HasConversion(new UtcDateTimeConverter());
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SeekerCommandTracker>(entity =>
|
||||
{
|
||||
entity.HasOne(s => s.ArrInstance)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.ArrInstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.Property(s => s.CreatedAt).HasConversion(new UtcDateTimeConverter());
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SearchQueueItem>(entity =>
|
||||
{
|
||||
entity.HasOne(s => s.ArrInstance)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.ArrInstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.Property(s => s.CreatedAt).HasConversion(new UtcDateTimeConverter());
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CustomFormatScoreEntry>(entity =>
|
||||
{
|
||||
entity.HasOne(s => s.ArrInstance)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.ArrInstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasIndex(s => new { s.ArrInstanceId, s.ExternalItemId, s.EpisodeId }).IsUnique();
|
||||
|
||||
entity.Property(s => s.LastSyncedAt).HasConversion(new UtcDateTimeConverter());
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CustomFormatScoreHistory>(entity =>
|
||||
{
|
||||
entity.HasOne(s => s.ArrInstance)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.ArrInstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasIndex(s => new { s.ArrInstanceId, s.ExternalItemId, s.EpisodeId });
|
||||
entity.HasIndex(s => s.RecordedAt);
|
||||
|
||||
entity.Property(s => s.RecordedAt).HasConversion(new UtcDateTimeConverter());
|
||||
});
|
||||
|
||||
// Configure BlacklistSyncState relationships and indexes
|
||||
modelBuilder.Entity<BlacklistSyncHistory>(entity =>
|
||||
{
|
||||
|
||||
1736
code/backend/Cleanuparr.Persistence/Migrations/Data/20260324200753_AddSeeker.Designer.cs
generated
Normal file
1736
code/backend/Cleanuparr.Persistence/Migrations/Data/20260324200753_AddSeeker.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user