mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-04-04 22:34:31 -04:00
Add missing and upgrade search (#507)
This commit is contained in:
@@ -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
@@ -0,0 +1,293 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSeeker : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "on_search_triggered",
|
||||
table: "notification_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "custom_format_score_entries",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
arr_instance_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
external_item_id = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
episode_id = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
item_type = table.Column<string>(type: "TEXT", nullable: false),
|
||||
title = table.Column<string>(type: "TEXT", nullable: false),
|
||||
file_id = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
current_score = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
cutoff_score = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
quality_profile_name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
last_synced_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_custom_format_score_entries", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_custom_format_score_entries_arr_instances_arr_instance_id",
|
||||
column: x => x.arr_instance_id,
|
||||
principalTable: "arr_instances",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "custom_format_score_history",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
arr_instance_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
external_item_id = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
episode_id = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
item_type = table.Column<string>(type: "TEXT", nullable: false),
|
||||
title = table.Column<string>(type: "TEXT", nullable: false),
|
||||
score = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
cutoff_score = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
recorded_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_custom_format_score_history", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_custom_format_score_history_arr_instances_arr_instance_id",
|
||||
column: x => x.arr_instance_id,
|
||||
principalTable: "arr_instances",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "search_queue",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
arr_instance_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
item_id = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
series_id = table.Column<long>(type: "INTEGER", nullable: true),
|
||||
search_type = table.Column<string>(type: "TEXT", nullable: true),
|
||||
title = table.Column<string>(type: "TEXT", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_search_queue", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_search_queue_arr_instances_arr_instance_id",
|
||||
column: x => x.arr_instance_id,
|
||||
principalTable: "arr_instances",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "seeker_command_trackers",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
arr_instance_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
command_id = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
event_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
external_item_id = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
item_title = table.Column<string>(type: "TEXT", nullable: false),
|
||||
item_type = table.Column<string>(type: "TEXT", nullable: false),
|
||||
season_number = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
status = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_seeker_command_trackers", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_seeker_command_trackers_arr_instances_arr_instance_id",
|
||||
column: x => x.arr_instance_id,
|
||||
principalTable: "arr_instances",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "seeker_configs",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
search_enabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
search_interval = table.Column<ushort>(type: "INTEGER", nullable: false),
|
||||
proactive_search_enabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
selection_strategy = table.Column<string>(type: "TEXT", nullable: false),
|
||||
monitored_only = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
use_cutoff = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
use_custom_format_score = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
use_round_robin = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_seeker_configs", x => x.id);
|
||||
});
|
||||
|
||||
// Migrate old data
|
||||
migrationBuilder.InsertData(
|
||||
table: "seeker_configs",
|
||||
columns: new[] { "id", "search_enabled", "search_interval", "proactive_search_enabled", "selection_strategy", "monitored_only", "use_cutoff", "use_custom_format_score", "use_round_robin" },
|
||||
values: new object[] { Guid.NewGuid(), true, 10, false, "balancedweighted", true, true, true, true });
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE seeker_configs SET search_enabled = (
|
||||
SELECT COALESCE(g.search_enabled, 1) FROM general_configs g LIMIT 1
|
||||
)");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "seeker_history",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
arr_instance_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
external_item_id = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
item_type = table.Column<string>(type: "TEXT", nullable: false),
|
||||
season_number = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
cycle_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
last_searched_at = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
item_title = table.Column<string>(type: "TEXT", nullable: false),
|
||||
search_count = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
is_dry_run = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_seeker_history", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_seeker_history_arr_instances_arr_instance_id",
|
||||
column: x => x.arr_instance_id,
|
||||
principalTable: "arr_instances",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "seeker_instance_configs",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
arr_instance_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
enabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
skip_tags = table.Column<string>(type: "TEXT", nullable: false),
|
||||
last_processed_at = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
current_cycle_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
total_eligible_items = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
active_download_limit = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
min_cycle_time_days = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_seeker_instance_configs", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_seeker_instance_configs_arr_instances_arr_instance_id",
|
||||
column: x => x.arr_instance_id,
|
||||
principalTable: "arr_instances",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "search_delay",
|
||||
table: "general_configs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "search_enabled",
|
||||
table: "general_configs");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_custom_format_score_entries_arr_instance_id_external_item_id_episode_id",
|
||||
table: "custom_format_score_entries",
|
||||
columns: new[] { "arr_instance_id", "external_item_id", "episode_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_custom_format_score_history_arr_instance_id_external_item_id_episode_id",
|
||||
table: "custom_format_score_history",
|
||||
columns: new[] { "arr_instance_id", "external_item_id", "episode_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_custom_format_score_history_recorded_at",
|
||||
table: "custom_format_score_history",
|
||||
column: "recorded_at");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_search_queue_arr_instance_id",
|
||||
table: "search_queue",
|
||||
column: "arr_instance_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_seeker_command_trackers_arr_instance_id",
|
||||
table: "seeker_command_trackers",
|
||||
column: "arr_instance_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_cycle_id",
|
||||
table: "seeker_history",
|
||||
columns: new[] { "arr_instance_id", "external_item_id", "item_type", "season_number", "cycle_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_seeker_instance_configs_arr_instance_id",
|
||||
table: "seeker_instance_configs",
|
||||
column: "arr_instance_id",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "custom_format_score_entries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "custom_format_score_history");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "search_queue");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "seeker_command_trackers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "seeker_configs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "seeker_history");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "seeker_instance_configs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "on_search_triggered",
|
||||
table: "notification_configs");
|
||||
|
||||
migrationBuilder.AddColumn<ushort>(
|
||||
name: "search_delay",
|
||||
table: "general_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: (ushort)0);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "search_enabled",
|
||||
table: "general_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user