mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-12 05:19:43 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33e948d1e7 | ||
|
|
9f551f151e | ||
|
|
9447cb37c0 | ||
|
|
8183b324a0 | ||
|
|
a6a25de19c |
No files matched your search
@@ -140,8 +140,22 @@ public sealed class SeekerConfigController : ControllerBase
|
||||
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
// Update Quartz trigger if SearchInterval changed
|
||||
if (config.SearchInterval != previousInterval)
|
||||
// Start/stop Seeker based on SearchEnabled toggle
|
||||
if (config.SearchEnabled != previousSearchEnabled)
|
||||
{
|
||||
if (config.SearchEnabled)
|
||||
{
|
||||
_logger.LogInformation("SearchEnabled turned on, starting Seeker job");
|
||||
await _jobManagementService.StartJob(JobType.Seeker, null, config.ToCronExpression());
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("SearchEnabled turned off, stopping Seeker job");
|
||||
await _jobManagementService.StopJob(JobType.Seeker);
|
||||
}
|
||||
}
|
||||
// Update Quartz trigger if SearchInterval changed (only while search is enabled)
|
||||
else if (config.SearchEnabled && config.SearchInterval != previousInterval)
|
||||
{
|
||||
_logger.LogInformation("Search interval changed from {Old} to {New} minutes, updating Seeker schedule",
|
||||
previousInterval, config.SearchInterval);
|
||||
|
||||
@@ -185,7 +185,10 @@ public class BackgroundJobManager : IHostedService
|
||||
public async Task RegisterSeekerJob(SeekerConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AddJobWithoutTrigger<SeekerJob>(cancellationToken);
|
||||
await AddTriggersForJob<SeekerJob>(config.ToCronExpression(), cancellationToken);
|
||||
if (config.SearchEnabled)
|
||||
{
|
||||
await AddTriggersForJob<SeekerJob>(config.ToCronExpression(), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -293,6 +293,53 @@ public class SeekerTests : IDisposable
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenActiveDownloadLimitNotReached_BecauseSameDownloadId_DoesNotSkip()
|
||||
{
|
||||
// Arrange — season pack: 2 queue records share the same DownloadId, so it's 1 unique download
|
||||
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
|
||||
config.SearchEnabled = true;
|
||||
config.ProactiveSearchEnabled = true;
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext);
|
||||
|
||||
_fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
|
||||
{
|
||||
ArrInstanceId = radarrInstance.Id,
|
||||
ArrInstance = radarrInstance,
|
||||
Enabled = true,
|
||||
ActiveDownloadLimit = 2
|
||||
});
|
||||
await _fixture.DataContext.SaveChangesAsync();
|
||||
|
||||
var mockArrClient = new Mock<IArrClient>();
|
||||
|
||||
// 2 queue records with the same DownloadId (season pack) — only 1 unique download
|
||||
QueueRecord[] activeDownloads =
|
||||
[
|
||||
new() { Id = 1, Title = "Episode 1", DownloadId = "same-hash", Protocol = "torrent", SizeLeft = 1000, MovieId = 10, TrackedDownloadState = "downloading" },
|
||||
new() { Id = 2, Title = "Episode 2", DownloadId = "same-hash", Protocol = "torrent", SizeLeft = 2000, MovieId = 20, TrackedDownloadState = "downloading" }
|
||||
];
|
||||
_fixture.ArrQueueIterator
|
||||
.Setup(x => x.Iterate(mockArrClient.Object, It.IsAny<ArrInstance>(), It.IsAny<Func<IReadOnlyList<QueueRecord>, Task>>()))
|
||||
.Returns<IArrClient, ArrInstance, Func<IReadOnlyList<QueueRecord>, Task>>((_, _, action) => action(activeDownloads));
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.Setup(x => x.GetClient(InstanceType.Radarr, It.IsAny<float>()))
|
||||
.Returns(mockArrClient.Object);
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert — search should NOT be skipped because only 1 unique download (< limit of 2)
|
||||
// The cycle completes (no eligible items) but the point is it wasn't blocked by the limit
|
||||
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs.FirstAsync();
|
||||
Assert.NotNull(instanceConfig.LastProcessedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Radarr_ExcludesMoviesAlreadyInQueue()
|
||||
{
|
||||
|
||||
@@ -282,7 +282,11 @@ public sealed class Seeker : IHandler
|
||||
// Check active download limit using the fetched queue data
|
||||
if (instanceConfig.ActiveDownloadLimit > 0)
|
||||
{
|
||||
int activeDownloads = queueRecords.Count(r => r.SizeLeft > 0);
|
||||
int activeDownloads = queueRecords
|
||||
.Where(r => r.SizeLeft > 0)
|
||||
.Select(r => r.DownloadId)
|
||||
.Distinct()
|
||||
.Count();
|
||||
if (activeDownloads >= instanceConfig.ActiveDownloadLimit)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
|
||||
Generated
+508
-544
File diff suppressed because it is too large.
Load diff
@@ -52,10 +52,11 @@ export class AppHubService extends HubService {
|
||||
this._logs.set([...logs].reverse());
|
||||
});
|
||||
|
||||
// Single event
|
||||
// Single event (deduplicate by ID to handle updates like search completion)
|
||||
connection.on('EventReceived', (event: AppEvent) => {
|
||||
this._events.update((events) => {
|
||||
const updated = [event, ...events];
|
||||
const filtered = events.filter((e) => e.id !== event.id);
|
||||
const updated = [event, ...filtered];
|
||||
return updated.length > MAX_BUFFER ? updated.slice(0, MAX_BUFFER) : updated;
|
||||
});
|
||||
});
|
||||
@@ -65,10 +66,11 @@ export class AppHubService extends HubService {
|
||||
this._events.set(events);
|
||||
});
|
||||
|
||||
// Single manual event
|
||||
// Single manual event (deduplicate by ID)
|
||||
connection.on('ManualEventReceived', (event: ManualEvent) => {
|
||||
this._manualEvents.update((events) => {
|
||||
const updated = [event, ...events];
|
||||
const filtered = events.filter((e) => e.id !== event.id);
|
||||
const updated = [event, ...filtered];
|
||||
return updated.length > MAX_BUFFER ? updated.slice(0, MAX_BUFFER) : updated;
|
||||
});
|
||||
});
|
||||
@@ -78,10 +80,11 @@ export class AppHubService extends HubService {
|
||||
this._manualEvents.set(events);
|
||||
});
|
||||
|
||||
// Single strike
|
||||
// Single strike (deduplicate by ID)
|
||||
connection.on('StrikeReceived', (strike: RecentStrike) => {
|
||||
this._strikes.update((strikes) => {
|
||||
const updated = [strike, ...strikes];
|
||||
const filtered = strikes.filter((s) => s.id !== strike.id);
|
||||
const updated = [strike, ...filtered];
|
||||
return updated.length > MAX_BUFFER ? updated.slice(0, MAX_BUFFER) : updated;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,8 @@
|
||||
<app-toggle label="Use Custom Format Score" [(checked)]="useCustomFormatScore"
|
||||
hint="Search for upgrades when a file's custom format score is below the quality profile's cutoff format score"
|
||||
helpKey="seeker:useCustomFormatScore" />
|
||||
<app-toggle label="Round Robin" [checked]="useRoundRobin()" (checkedChange)="toggleRoundRobin($event)"
|
||||
<app-toggle label="Round Robin" [(checked)]="useRoundRobin"
|
||||
[beforeChange]="confirmRoundRobin"
|
||||
hint="Process one instance per run to spread indexer load"
|
||||
helpKey="seeker:useRoundRobin" />
|
||||
<app-number-input
|
||||
|
||||
@@ -152,25 +152,17 @@ export class SeekerComponent implements OnInit, HasPendingChanges {
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
async toggleRoundRobin(newValue: boolean): Promise<void> {
|
||||
readonly confirmRoundRobin = async (newValue: boolean): Promise<boolean> => {
|
||||
if (!newValue) {
|
||||
const confirmed = await this.confirm.confirm({
|
||||
return this.confirm.confirm({
|
||||
title: 'Disable Round Robin',
|
||||
message: 'Disabling round robin will trigger a search for each enabled arr instance per run. This could result in too many requests to your indexers and potentially get you banned.',
|
||||
confirmLabel: 'Disable',
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
// The toggle already flipped its internal state to false.
|
||||
// Sync our signal to false first, then restore to true in the next microtask
|
||||
// so Angular detects an actual change and pushes it back to the toggle.
|
||||
this.useRoundRobin.set(false);
|
||||
queueMicrotask(() => this.useRoundRobin.set(true));
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.useRoundRobin.set(newValue);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
toggleInstance(index: number): void {
|
||||
this.instances.update(instances => {
|
||||
|
||||
@@ -17,12 +17,30 @@ export class ToggleComponent {
|
||||
disabled = input(false);
|
||||
hint = input<string>();
|
||||
helpKey = input<string>();
|
||||
beforeChange = input<(newValue: boolean) => Promise<boolean> | boolean>();
|
||||
checked = model(false);
|
||||
|
||||
toggle(): void {
|
||||
if (!this.disabled()) {
|
||||
this.checked.set(!this.checked());
|
||||
private pending = false;
|
||||
|
||||
async toggle(): Promise<void> {
|
||||
if (this.disabled() || this.pending) return;
|
||||
|
||||
const newValue = !this.checked();
|
||||
const guard = this.beforeChange();
|
||||
|
||||
if (guard) {
|
||||
this.pending = true;
|
||||
try {
|
||||
const allowed = await guard(newValue);
|
||||
if (!allowed) return;
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
this.pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.checked.set(newValue);
|
||||
}
|
||||
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
|
||||
Reference in new issue
Block a user