Compare commits

...
5 Commits
9 changed files with 616 additions and 570 deletions

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(
+508 -544
View File
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 {