changed cycle progress to count search units

This commit is contained in:
Flaminel committed 2026-10-20 15:55:53 +03:00
1 parent af1823779a
commit 081d50ca1b
13 files changed
+4402 -68

No files matched your search

@@ -4,8 +4,12 @@ using Cleanuparr.Api.Features.Seeker.Controllers;
using Cleanuparr.Api.Tests.Features.Seeker.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.Seeker;
using Cleanuparr.Persistence.Models.Events;
using Cleanuparr.Persistence.Models.State;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Time.Testing;
using Shouldly;
@@ -74,6 +78,123 @@ public class SearchStatsControllerTests : IDisposable
#endregion
#region GetSummary PerInstanceStats
[Fact]
public async Task GetSummary_PerInstanceStats_UsesTheCurrentCycleRow()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
Guid currentCycleId = Guid.NewGuid();
AddInstanceConfig(radarr.Id, currentCycleId);
AddCycle(currentCycleId, radarr.Id, eligibleNow: 10, remainingUnits: 4, startedAt: Now.AddDays(-2));
SearchStatsSummaryResponse summary = Summary(await _controller.GetSummary());
InstanceSearchStat stat = summary.PerInstanceStats.ShouldHaveSingleItem();
stat.CycleItemsTotal.ShouldBe(10);
stat.CycleItemsRemaining.ShouldBe(4);
stat.CycleItemsSearched.ShouldBe(6);
stat.CycleStartedAt.ShouldBe(Now.AddDays(-2));
stat.CycleCompletedAt.ShouldBeNull();
}
[Fact]
public async Task GetSummary_PerInstanceStats_LastCycle_IsTheNewestCompletedOtherCycle()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
Guid currentCycleId = Guid.NewGuid();
AddInstanceConfig(radarr.Id, currentCycleId);
AddCycle(currentCycleId, radarr.Id, eligibleNow: 5, remainingUnits: 5, startedAt: Now);
Guid olderCompletedId = Guid.NewGuid();
Guid newerCompletedId = Guid.NewGuid();
AddCycle(olderCompletedId, radarr.Id, eligibleNow: 3, remainingUnits: 0, startedAt: Now.AddDays(-20), completedAt: Now.AddDays(-19));
AddCycle(newerCompletedId, radarr.Id, eligibleNow: 3, remainingUnits: 0, startedAt: Now.AddDays(-10), completedAt: Now.AddDays(-9));
SearchStatsSummaryResponse summary = Summary(await _controller.GetSummary());
InstanceSearchStat stat = summary.PerInstanceStats.ShouldHaveSingleItem();
stat.LastCycleStartedAt.ShouldBe(Now.AddDays(-10));
stat.LastCycleCompletedAt.ShouldBe(Now.AddDays(-9));
}
[Fact]
public async Task GetSummary_PerInstanceStats_DryRunOn_ShowsNewestDryCycleAndIgnoresLiveCycles()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
(await _dataContext.GeneralConfigs.FirstAsync()).DryRun = true;
await _dataContext.SaveChangesAsync();
Guid currentCycleId = Guid.NewGuid();
AddInstanceConfig(radarr.Id, currentCycleId);
AddCycle(currentCycleId, radarr.Id, eligibleNow: 99, remainingUnits: 99, startedAt: Now.AddDays(-30), isDryRun: false);
Guid dryCycleId = Guid.NewGuid();
AddCycle(dryCycleId, radarr.Id, eligibleNow: 7, remainingUnits: 2, startedAt: Now.AddHours(-1), isDryRun: true);
SearchStatsSummaryResponse summary = Summary(await _controller.GetSummary());
InstanceSearchStat stat = summary.PerInstanceStats.ShouldHaveSingleItem();
stat.CurrentCycleId.ShouldBe(dryCycleId);
stat.CycleItemsTotal.ShouldBe(7);
stat.CycleItemsRemaining.ShouldBe(2);
}
[Fact]
public async Task GetSummary_PerInstanceStats_NoCycleRow_ValuesAreZeroOrNull()
{
var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext);
AddInstanceConfig(radarr.Id, Guid.NewGuid());
SearchStatsSummaryResponse summary = Summary(await _controller.GetSummary());
InstanceSearchStat stat = summary.PerInstanceStats.ShouldHaveSingleItem();
stat.CycleItemsSearched.ShouldBe(0);
stat.CycleItemsTotal.ShouldBe(0);
stat.CycleItemsRemaining.ShouldBe(0);
stat.CycleStartedAt.ShouldBeNull();
stat.CycleCompletedAt.ShouldBeNull();
stat.LastCycleStartedAt.ShouldBeNull();
stat.LastCycleCompletedAt.ShouldBeNull();
}
private void AddInstanceConfig(Guid arrInstanceId, Guid currentCycleId)
{
_dataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig
{
ArrInstanceId = arrInstanceId,
Enabled = true,
CurrentCycleId = currentCycleId,
});
_dataContext.SaveChanges();
}
private void AddCycle(
Guid id,
Guid arrInstanceId,
int eligibleNow,
int remainingUnits,
DateTimeOffset startedAt,
DateTimeOffset? completedAt = null,
bool isDryRun = false)
{
_eventsContext.SeekerCycles.Add(new SeekerCycle
{
Id = id,
ArrInstanceId = arrInstanceId,
StartedAt = startedAt,
CompletedAt = completedAt,
CompletionReason = completedAt is null ? null : SeekerCycleCompletionReason.Completed,
EligibleAtStart = eligibleNow,
EligibleNow = eligibleNow,
RemainingUnits = remainingUnits,
IsDryRun = isDryRun,
});
_eventsContext.SaveChanges();
}
#endregion
#region GetEvents with SearchEventData
[Fact]
@@ -12,5 +12,9 @@ public sealed record InstanceSearchStat
public Guid? CurrentCycleId { get; init; }
public int CycleItemsSearched { get; init; }
public int CycleItemsTotal { get; init; }
public int CycleItemsRemaining { get; init; }
public DateTimeOffset? CycleStartedAt { get; init; }
public DateTimeOffset? CycleCompletedAt { get; init; }
public DateTimeOffset? LastCycleStartedAt { get; init; }
public DateTimeOffset? LastCycleCompletedAt { get; init; }
}
@@ -75,24 +75,25 @@ public sealed class SearchStatsController : ControllerBase
})
.ToListAsync();
// Count items searched in current cycle per instance
List<Guid> currentCycleIds = instanceConfigs.Select(ic => ic.CurrentCycleId).ToList();
var cycleItemsByInstance = await _eventsContext.SeekerHistory
// Dry run searches in its own cycles, so show those while it is on.
bool isDryRun = (await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync()).DryRun;
List<Guid> instanceIds = instanceConfigs.Select(ic => ic.ArrInstanceId).ToList();
List<SeekerCycle> cycles = await _eventsContext.SeekerCycles
.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 = (DateTimeOffset?)g.Min(h => h.LastSearchedAt),
})
.Where(c => instanceIds.Contains(c.ArrInstanceId) && c.IsDryRun == isDryRun)
.ToListAsync();
var perInstanceStats = instanceConfigs.Select(ic =>
{
var history = historyByInstance.FirstOrDefault(h => h.InstanceId == ic.ArrInstanceId);
var cycleProgress = cycleItemsByInstance.FirstOrDefault(c => c.InstanceId == ic.ArrInstanceId);
List<SeekerCycle> instanceCycles = cycles
.Where(c => c.ArrInstanceId == ic.ArrInstanceId)
.OrderByDescending(c => c.StartedAt)
.ToList();
SeekerCycle? current = isDryRun
? instanceCycles.FirstOrDefault()
: instanceCycles.FirstOrDefault(c => c.Id == ic.CurrentCycleId);
SeekerCycle? last = instanceCycles.FirstOrDefault(c => c.CompletedAt != null && c.Id != current?.Id);
return new InstanceSearchStat
{
InstanceId = ic.ArrInstanceId,
@@ -102,10 +103,14 @@ public sealed class SearchStatsController : ControllerBase
TotalSearchCount = history?.TotalSearchCount ?? 0,
LastSearchedAt = history?.LastSearchedAt,
LastProcessedAt = ic.LastProcessedAt,
CurrentCycleId = ic.CurrentCycleId,
CycleItemsSearched = cycleProgress?.CycleItemsSearched ?? 0,
CycleItemsTotal = ic.TotalEligibleItems,
CycleStartedAt = cycleProgress?.CycleStartedAt,
CurrentCycleId = current?.Id ?? ic.CurrentCycleId,
CycleItemsSearched = current is null ? 0 : Math.Max(0, current.EligibleNow - current.RemainingUnits),
CycleItemsTotal = current?.EligibleNow ?? 0,
CycleItemsRemaining = current?.RemainingUnits ?? 0,
CycleStartedAt = current?.StartedAt,
CycleCompletedAt = current?.CompletedAt,
LastCycleStartedAt = last?.StartedAt,
LastCycleCompletedAt = last?.CompletedAt,
};
}).ToList();
@@ -1772,8 +1772,7 @@ public class SeekerTests : IDisposable
Enabled = true,
MonitoredOnly = false,
CurrentCycleId = currentCycleId,
MinCycleTimeDays = 7,
TotalEligibleItems = 2
MinCycleTimeDays = 7
});
// Cycle started 2 days ago — within the 7-day minimum
@@ -1855,8 +1854,7 @@ public class SeekerTests : IDisposable
Enabled = true,
MonitoredOnly = false,
CurrentCycleId = currentCycleId,
MinCycleTimeDays = 7,
TotalEligibleItems = 2
MinCycleTimeDays = 7
});
// Cycle started 10 days ago — beyond the 7-day minimum
@@ -2013,8 +2011,7 @@ public class SeekerTests : IDisposable
Enabled = true,
MonitoredOnly = false,
CurrentCycleId = currentCycleId,
MinCycleTimeDays = 7,
TotalEligibleItems = 1
MinCycleTimeDays = 7
});
// Series history — season already searched in current cycle (started 2 days ago)
@@ -2089,8 +2086,7 @@ public class SeekerTests : IDisposable
Enabled = true,
MonitoredOnly = false,
CurrentCycleId = currentCycleId,
MinCycleTimeDays = 7,
TotalEligibleItems = 1
MinCycleTimeDays = 7
});
// Series history — season already searched in current cycle (started 10 days ago)
@@ -2174,7 +2170,6 @@ public class SeekerTests : IDisposable
MonitoredOnly = false,
CurrentCycleId = cycleIdA,
MinCycleTimeDays = 30,
TotalEligibleItems = 1,
LastProcessedAt = now.AddDays(-5) // Oldest — round-robin tries this first
});
_fixture.EventsContext.SeekerHistory.Add(new SeekerHistory
@@ -2198,7 +2193,6 @@ public class SeekerTests : IDisposable
MonitoredOnly = false,
CurrentCycleId = cycleIdB,
MinCycleTimeDays = 5,
TotalEligibleItems = 1,
LastProcessedAt = now.AddDays(-1)
});
// No history for instance B — it hasn't searched anything yet
@@ -2281,8 +2275,7 @@ public class SeekerTests : IDisposable
Enabled = true,
MonitoredOnly = false,
CurrentCycleId = currentCycleId,
MinCycleTimeDays = 30,
TotalEligibleItems = 2 // Stale value from previous run
MinCycleTimeDays = 30
});
// History: 2 items searched in current cycle
@@ -2373,8 +2366,7 @@ public class SeekerTests : IDisposable
Enabled = true,
MonitoredOnly = false,
CurrentCycleId = currentCycleId,
MinCycleTimeDays = 30,
TotalEligibleItems = 2 // Stale value from previous run
MinCycleTimeDays = 30
});
// History: items 1 and 2 were searched
@@ -4618,9 +4610,9 @@ public class SeekerTests : IDisposable
}
[Fact]
public async Task ExecuteAsync_Sonarr_TotalEligibleItems_CountsSeriesWithAnEligibleSeason()
public async Task ExecuteAsync_Sonarr_Cycle_EligibleNow_CountsEligibleSeasonsAcrossSeries()
{
// Arrange: series 10 has two eligible seasons, series 20 has one; TotalEligibleItems counts series, not seasons
// Arrange: series 10 has two eligible seasons, series 20 has one; the cycle counts seasons, not series
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
config.SearchEnabled = true;
config.ProactiveSearchEnabled = true;
@@ -4673,10 +4665,10 @@ public class SeekerTests : IDisposable
// Act
await sut.ExecuteAsync();
// Assert: two series had an eligible season, even though series 10 contributed two seasons
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
.FirstAsync(s => s.ArrInstanceId == sonarrInstance.Id);
instanceConfig.TotalEligibleItems.ShouldBe(2);
// Assert
var cycle = await _fixture.EventsContext.SeekerCycles
.FirstAsync(c => c.ArrInstanceId == sonarrInstance.Id && !c.IsDryRun);
cycle.EligibleNow.ShouldBe(3);
}
[Fact]
@@ -5236,7 +5228,7 @@ public class SeekerTests : IDisposable
}
[Fact]
public async Task ExecuteAsync_Radarr_UpdatesTotalEligibleItems()
public async Task ExecuteAsync_Radarr_Cycle_EligibleNow_UpdatesToMatchEligibleMovies()
{
// Arrange
var config = await _fixture.DataContext.SeekerConfigs.FirstAsync();
@@ -5252,8 +5244,7 @@ public class SeekerTests : IDisposable
ArrInstanceId = radarrInstance.Id,
ArrInstance = radarrInstance,
Enabled = true,
MonitoredOnly = false,
TotalEligibleItems = 0 // Start at 0
MonitoredOnly = false
});
await _fixture.DataContext.SaveChangesAsync();
await _fixture.EventsContext.SaveChangesAsync();
@@ -5291,10 +5282,10 @@ public class SeekerTests : IDisposable
// Act
await sut.ExecuteAsync();
// Assert — TotalEligibleItems updated to 3
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
.FirstAsync(s => s.ArrInstanceId == radarrInstance.Id);
instanceConfig.TotalEligibleItems.ShouldBe(3);
// Assert
var cycle = await _fixture.EventsContext.SeekerCycles
.FirstAsync(c => c.ArrInstanceId == radarrInstance.Id && !c.IsDryRun);
cycle.EligibleNow.ShouldBe(3);
}
#endregion
@@ -7338,10 +7329,6 @@ public class SeekerTests : IDisposable
await sut.ExecuteAsync();
// Assert
var instanceConfig = await _fixture.DataContext.SeekerInstanceConfigs
.FirstAsync(s => s.ArrInstanceId == sonarrInstance.Id);
instanceConfig.TotalEligibleItems.ShouldBe(1);
var cycle = await _fixture.EventsContext.SeekerCycles
.FirstAsync(c => c.ArrInstanceId == sonarrInstance.Id && !c.IsDryRun);
cycle.EligibleNow.ShouldBe(2);
@@ -644,7 +644,6 @@ public sealed class Seeker : IHandler
}
int eligibleCount = candidates.Count;
instanceConfig.TotalEligibleItems = eligibleCount;
cycle.EligibleNow = eligibleCount;
cycle.RemainingUnits = 0;
@@ -842,7 +841,6 @@ public sealed class Seeker : IHandler
.GroupBy(q => (q.SeriesId, q.SeasonNumber))
.ToDictionary(g => g.Key, g => g.Select(q => q.Reason).ToList());
instanceConfig.TotalEligibleItems = seasons.Keys.Select(s => s.SeriesId).Distinct().Count();
cycle.EligibleNow = seasons.Count;
cycle.RemainingUnits = 0;
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Postgres.Migrations.Data
{
/// <inheritdoc />
public partial class RemoveSeekerTotalEligibleItems : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "total_eligible_items",
schema: "data",
table: "seeker_instance_configs");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "total_eligible_items",
schema: "data",
table: "seeker_instance_configs",
type: "integer",
nullable: false,
defaultValue: 0);
}
}
}
@@ -1740,10 +1740,6 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Data
.HasColumnType("text[]")
.HasColumnName("skip_tags");
b.Property<int>("TotalEligibleItems")
.HasColumnType("integer")
.HasColumnName("total_eligible_items");
b.Property<bool>("UseCustomFormatScore")
.HasColumnType("boolean")
.HasColumnName("use_custom_format_score");
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class RemoveSeekerTotalEligibleItems : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "total_eligible_items",
table: "seeker_instance_configs");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "total_eligible_items",
table: "seeker_instance_configs",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
}
}
@@ -1736,10 +1736,6 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("skip_tags");
b.Property<int>("TotalEligibleItems")
.HasColumnType("INTEGER")
.HasColumnName("total_eligible_items");
b.Property<bool>("UseCustomFormatScore")
.HasColumnType("INTEGER")
.HasColumnName("use_custom_format_score");
@@ -52,12 +52,6 @@ public sealed record SeekerInstanceConfig
/// </summary>
public Guid CurrentCycleId { get; set; } = Guid.NewGuid();
/// <summary>
/// Total number of eligible items in the library for this instance.
/// Updated each time the Seeker processes the instance.
/// </summary>
public int TotalEligibleItems { get; set; }
/// <summary>
/// Skip proactive search cycles when the number of actively downloading items
/// (SizeLeft > 0) in the arr queue is at or above this threshold. 0 = disabled.
+48 -5
View File
@@ -607,7 +607,9 @@ Console.WriteLine($"Built a media pool of {mediaPool.Count} distinct titles.");
// ---- Seeker per-instance configs: capacity-driven so "cycle progress" and "cycle duration"
// on the Seeker Stats page show real, non-zero values instead of "0 / N" and "-" ----
List<SeekerHistory> seekerHistory = [];
HashSet<(Guid, long, InstanceType, int, Guid)> seekerHistoryKeys = [];
List<SeekerCycle> seekerCycles = [];
// Matches the SeekerHistory unique index; EpisodeId and IsDryRun are always 0/false here.
HashSet<(Guid, long, InstanceType, int)> seekerHistoryKeys = [];
// Generic filler titles for library padding: real curated titles stay scarce (they're the
// ones the docs gallery pool knows by name), so background capacity/cycle rows that exist
@@ -659,6 +661,9 @@ foreach (ArrInstance instance in seekerInstances)
List<DateTimeOffset> curatedTimestamps = cycleTimestampsDesc.Take(cycleItemIds.Count).OrderBy(_ => rng.Next()).ToList();
List<DateTimeOffset> fillerTimestamps = cycleTimestampsDesc.Skip(cycleItemIds.Count).OrderBy(_ => rng.Next()).ToList();
int searchedUnits = 0;
DateTimeOffset cycleStartedAt = now;
for (int i = 0; i < cycleItemIds.Count; i++)
{
long externalId = cycleItemIds[i];
@@ -666,13 +671,19 @@ foreach (ArrInstance instance in seekerInstances)
? mediaPool.First(m => m.Instance?.Id == instance.Id && m.ExternalItemId == externalId).SeasonNumber
: 0;
string title = instanceType == InstanceType.Sonarr ? sonarrSeriesTitle[externalId] : radarrMovieTitle[externalId];
(Guid, long, InstanceType, int, Guid) key = (instance.Id, externalId, instanceType, seasonNumber, currentCycleId);
(Guid, long, InstanceType, int) key = (instance.Id, externalId, instanceType, seasonNumber);
if (!seekerHistoryKeys.Add(key))
{
continue;
}
searchedUnits++;
if (curatedTimestamps[i] < cycleStartedAt)
{
cycleStartedAt = curatedTimestamps[i];
}
seekerHistory.Add(new SeekerHistory
{
ArrInstanceId = instance.Id,
@@ -692,13 +703,19 @@ foreach (ArrInstance instance in seekerInstances)
{
long fillerId = 100_000 + f;
int seasonNumber = instanceType == InstanceType.Sonarr ? 1 : 0;
(Guid, long, InstanceType, int, Guid) key = (instance.Id, fillerId, instanceType, seasonNumber, currentCycleId);
(Guid, long, InstanceType, int) key = (instance.Id, fillerId, instanceType, seasonNumber);
if (!seekerHistoryKeys.Add(key))
{
continue;
}
searchedUnits++;
if (fillerTimestamps[f] < cycleStartedAt)
{
cycleStartedAt = fillerTimestamps[f];
}
seekerHistory.Add(new SeekerHistory
{
ArrInstanceId = instance.Id,
@@ -721,9 +738,34 @@ foreach (ArrInstance instance in seekerInstances)
MonitoredOnly = true,
ActiveDownloadLimit = 3,
MinCycleTimeDays = 7,
TotalEligibleItems = capacity,
CurrentCycleId = currentCycleId,
});
seekerCycles.Add(new SeekerCycle
{
Id = currentCycleId,
ArrInstanceId = instance.Id,
StartedAt = cycleStartedAt,
EligibleAtStart = capacity,
EligibleNow = capacity,
SearchedUnits = searchedUnits,
RemainingUnits = Math.Max(0, capacity - searchedUnits),
});
// A closed previous cycle, a few days long, ending before the current one started.
DateTimeOffset previousCompletedAt = cycleStartedAt.AddDays(-rng.Next(1, 4));
seekerCycles.Add(new SeekerCycle
{
ArrInstanceId = instance.Id,
StartedAt = previousCompletedAt.AddDays(-rng.Next(3, 8)),
CompletedAt = previousCompletedAt,
CompletionReason = SeekerCycleCompletionReason.Completed,
EligibleAtStart = capacity,
EligibleNow = capacity,
SearchedUnits = capacity,
RemainingUnits = 0,
GrabbedUnits = rng.Next(0, capacity / 3 + 1),
});
}
await data.SaveChangesAsync();
Console.WriteLine($"Seeded {dataDbPath}: 4 seeker instance configs (capacity-driven cycle progress).");
@@ -739,7 +781,7 @@ for (int i = 0; i < pastCycleHistoryCount; i++)
MediaItem media = Pick(sonarrRadarrForHistory);
Guid pastCycleId = Guid.NewGuid();
(Guid, long, InstanceType, int, Guid) key = (media.Instance!.Id, media.ExternalItemId, media.Type!.Value, media.SeasonNumber, pastCycleId);
(Guid, long, InstanceType, int) key = (media.Instance!.Id, media.ExternalItemId, media.Type!.Value, media.SeasonNumber);
if (!seekerHistoryKeys.Add(key))
{
@@ -760,6 +802,7 @@ for (int i = 0; i < pastCycleHistoryCount; i++)
});
}
events.SeekerHistory.AddRange(seekerHistory);
events.SeekerCycles.AddRange(seekerCycles);
await events.SaveChangesAsync();
// ---- Job runs: capped, recency/diurnal-biased sample per JobType (not literal cron replay,