diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index b780ab16..8afcfa3d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -445,6 +445,11 @@ public sealed class Seeker : IHandler SeekerProcessResult result = await SelectCandidatesAsync(config, instanceConfig, arrInstance, evaluation, cycle, isDryRun); + if (!result.Evaluation.WantedUnsupported) + { + RecordFunnel(result.Cycle, result.Evaluation); + } + await _eventsContext.SaveChangesAsync(cancellationToken); if (result.Candidates.Count == 0) @@ -452,7 +457,7 @@ public sealed class Seeker : IHandler _logger.LogDebug("No items selected for search on {InstanceName}", arrInstance.Name); if (!isDryRun) { - await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, result.AllLibraryIds, allHistoryExternalIds); + await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, result.Evaluation.AllLibraryIds, allHistoryExternalIds); } return false; } @@ -507,7 +512,7 @@ public sealed class Seeker : IHandler if (!isDryRun) { - await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, result.AllLibraryIds, allHistoryExternalIds); + await CleanupStaleHistoryAsync(arrInstance.Id, instanceType, result.Evaluation.AllLibraryIds, allHistoryExternalIds); } return true; @@ -524,11 +529,9 @@ public sealed class Seeker : IHandler SeekerCycle cycle, bool isDryRun) { - List allLibraryIds = evaluation.AllLibraryIds; - if (evaluation.WantedUnsupported) { - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, Cycle = cycle }; + return new SeekerProcessResult { Candidates = [], Cycle = cycle, Evaluation = evaluation }; } int eligibleCount = evaluation.Units.Count; @@ -544,7 +547,7 @@ public sealed class Seeker : IHandler if (blockedCount == eligibleCount) { - return await CompleteEmptyCycleAsync(cycle, instanceConfig, isDryRun, eligibleCount, arrInstance.Name, allLibraryIds); + return await CompleteEmptyCycleAsync(cycle, instanceConfig, isDryRun, arrInstance.Name, evaluation); } cycle.RemainingUnits = evaluation.Remaining.Count; @@ -554,7 +557,7 @@ public sealed class Seeker : IHandler SeekerCycle? next = await CompleteCycleAsync(cycle, instanceConfig, isDryRun, eligibleCount, arrInstance.Name); if (next is null) { - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, Cycle = cycle }; + return new SeekerProcessResult { Candidates = [], Cycle = cycle, Evaluation = evaluation }; } cycle = next; @@ -564,7 +567,7 @@ public sealed class Seeker : IHandler if (cycle.RemainingUnits == 0) { - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, Cycle = cycle }; + return new SeekerProcessResult { Candidates = [], Cycle = cycle, Evaluation = evaluation }; } } @@ -576,7 +579,18 @@ public sealed class Seeker : IHandler candidate.Name, candidate.ContentKind, arrInstance.Name, candidate.Reason); } - return new SeekerProcessResult { Candidates = candidates, AllLibraryIds = allLibraryIds, Cycle = cycle }; + return new SeekerProcessResult { Candidates = candidates, Cycle = cycle, Evaluation = evaluation }; + } + + private void RecordFunnel(SeekerCycle cycle, SeekerEvaluation evaluation) + { + cycle.LibraryItems = evaluation.AllLibraryIds.Count; + cycle.ReleasedItems = evaluation.ReleasedItems; + cycle.MonitoredItems = evaluation.MonitoredItems; + cycle.TagFilteredItems = evaluation.TagFilteredItems; + cycle.QueuedUnits = evaluation.Units.Count(u => u.BlockedBy is SeekerExclusionReason.InQueue); + cycle.RecentlyGrabbedUnits = evaluation.Units.Count(u => u.BlockedBy is SeekerExclusionReason.RecentlyGrabbed); + cycle.FunnelCountedAt = _timeProvider.GetUtcNow(); } private async Task UpdateSearchHistoryAsync( @@ -848,12 +862,11 @@ public sealed class Seeker : IHandler SeekerCycle cycle, SeekerInstanceConfig instanceConfig, bool isDryRun, - int eligibleCount, string instanceName, - List allLibraryIds) + SeekerEvaluation evaluation) { - SeekerCycle? next = await CompleteCycleAsync(cycle, instanceConfig, isDryRun, eligibleCount, instanceName); - return new SeekerProcessResult { Candidates = [], AllLibraryIds = allLibraryIds, Cycle = next ?? cycle }; + SeekerCycle? next = await CompleteCycleAsync(cycle, instanceConfig, isDryRun, evaluation.Units.Count, instanceName); + return new SeekerProcessResult { Candidates = [], Cycle = next ?? cycle, Evaluation = evaluation }; } /// diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerEvaluation.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerEvaluation.cs index 24a0c285..067af1c5 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerEvaluation.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerEvaluation.cs @@ -1,3 +1,5 @@ +using Cleanuparr.Domain.Enums; + namespace Cleanuparr.Infrastructure.Features.Seeker; /// @@ -12,6 +14,26 @@ internal sealed record SeekerEvaluation /// public bool WantedUnsupported { get; init; } + /// + /// Movies or series past the release and grace period stage. + /// + public int ReleasedItems { get; init; } + + /// + /// Released items past the monitored stage. + /// + public int MonitoredItems { get; init; } + + /// + /// Monitored items past the tag filters. + /// + public int TagFilteredItems { get; init; } + + /// + /// Items and episodes dropped before they became units. + /// + public List ItemExclusions { get; init; } = []; + /// /// Every unit that needs a search, including the blocked and searched ones. /// @@ -28,4 +50,11 @@ internal sealed record SeekerEvaluation public required Func WithoutCycleHistory { get; init; } public List Remaining => Units.Where(u => u.BlockedBy is null && !u.Searched).ToList(); + + /// + /// Everything that did not reach , with one reason each. + /// + public IEnumerable Excluded => ItemExclusions.Concat(Units + .Where(u => u.BlockedBy is not null || u.Searched) + .Select(u => new SeekerExclusion(u.Name, u.BlockedBy ?? SeekerExclusionReason.SearchedThisCycle))); } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerExclusion.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerExclusion.cs new file mode 100644 index 00000000..ea6f193b --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerExclusion.cs @@ -0,0 +1,8 @@ +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Infrastructure.Features.Seeker; + +/// +/// A movie, series, season or episode Seeker leaves out, with the first stage that dropped it. +/// +internal sealed record SeekerExclusion(string Name, SeekerExclusionReason Reason); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerProcessResult.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerProcessResult.cs index 9467e2af..81935bc8 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerProcessResult.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerProcessResult.cs @@ -8,10 +8,11 @@ namespace Cleanuparr.Infrastructure.Features.Seeker; internal sealed record SeekerProcessResult { public required List Candidates { get; init; } - public required List AllLibraryIds { get; init; } /// /// Cycle the candidates belong to. /// public required SeekerCycle Cycle { get; init; } + + public required SeekerEvaluation Evaluation { get; init; } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs index ac2aa4dd..3ed7b46e 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Seeker/SeekerUnitEvaluator.cs @@ -154,6 +154,9 @@ public sealed class SeekerUnitEvaluator DateTimeOffset graceCutoff = _timeProvider.GetUtcNow().AddHours(-config.PostReleaseGraceHours); List allLibraryIds = new(); + List exclusions = []; + int releasedCount = 0; + int monitoredCount = 0; List released = new(); await foreach (SearchableMovie movie in _radarrClient.StreamAllMoviesAsync(arrInstance, cancellationToken)) { @@ -161,21 +164,30 @@ public sealed class SeekerUnitEvaluator if (movie.Status is not "released") { + exclusions.Add(new SeekerExclusion(movie.Title, SeekerExclusionReason.NotReleased)); continue; } if (!IsMoviePastGracePeriod(movie, graceCutoff)) { + exclusions.Add(new SeekerExclusion(movie.Title, SeekerExclusionReason.InGracePeriod)); continue; } + releasedCount++; + if (instanceConfig.MonitoredOnly && !movie.Monitored) { + exclusions.Add(new SeekerExclusion(movie.Title, SeekerExclusionReason.NotMonitored)); continue; } - if (!PassesTagFilter(movie.Tags, tagsById, skipTagSet, includeTagSet)) + monitoredCount++; + + SeekerExclusionReason? tagExclusion = GetTagExclusion(movie.Tags, tagsById, skipTagSet, includeTagSet); + if (tagExclusion is not null) { + exclusions.Add(new SeekerExclusion(movie.Title, tagExclusion.Value)); continue; } @@ -207,6 +219,10 @@ public sealed class SeekerUnitEvaluator { AllLibraryIds = allLibraryIds, WantedUnsupported = true, + ReleasedItems = releasedCount, + MonitoredItems = monitoredCount, + TagFilteredItems = released.Count, + ItemExclusions = exclusions, Units = [], LastSearchedByItem = lastSearchedByItem, WithoutCycleHistory = Unsupported, @@ -249,6 +265,8 @@ public sealed class SeekerUnitEvaluator _logger.LogDebug("skip | upgrades disabled in profile | {Title} | {InstanceName}", movie.Title, arrInstance.Name); } + exclusions.Add(new SeekerExclusion(movie.Title, + upgradesDisabled ? SeekerExclusionReason.UpgradesDisabled : SeekerExclusionReason.NothingToSearch)); continue; } @@ -270,6 +288,10 @@ public sealed class SeekerUnitEvaluator SeekerEvaluation Build(Dictionary searched) => new() { AllLibraryIds = allLibraryIds, + ReleasedItems = releasedCount, + MonitoredItems = monitoredCount, + TagFilteredItems = released.Count, + ItemExclusions = exclusions, Units = units.Select(u => u with { Searched = searched.ContainsKey(u.ItemId) }).ToList(), LastSearchedByItem = searched, WithoutCycleHistory = () => Build(new Dictionary()), @@ -303,6 +325,9 @@ public sealed class SeekerUnitEvaluator DateTimeOffset graceCutoff = _timeProvider.GetUtcNow().AddHours(-config.PostReleaseGraceHours); List allLibraryIds = new(); + List exclusions = []; + int releasedCount = 0; + int monitoredCount = 0; Dictionary seriesById = new(); await foreach (SearchableSeries series in _sonarrClient.StreamAllSeriesAsync(arrInstance, cancellationToken)) { @@ -310,16 +335,24 @@ public sealed class SeekerUnitEvaluator if (series.Status is not ("continuing" or "ended" or "released")) { + exclusions.Add(new SeekerExclusion(series.Title, SeekerExclusionReason.NotReleased)); continue; } + releasedCount++; + if (instanceConfig.MonitoredOnly && !series.Monitored) { + exclusions.Add(new SeekerExclusion(series.Title, SeekerExclusionReason.NotMonitored)); continue; } - if (!PassesTagFilter(series.Tags, tagsById, skipTagSet, includeTagSet)) + monitoredCount++; + + SeekerExclusionReason? tagExclusion = GetTagExclusion(series.Tags, tagsById, skipTagSet, includeTagSet); + if (tagExclusion is not null) { + exclusions.Add(new SeekerExclusion(series.Title, tagExclusion.Value)); continue; } @@ -327,9 +360,37 @@ public sealed class SeekerUnitEvaluator } List wanted = []; + DateTimeOffset now = _timeProvider.GetUtcNow(); + HashSet excludedEpisodeIds = []; + HashSet seriesWithExcludedEpisodes = []; - bool IsReleasedEpisodeOfCandidate(SearchableEpisode e) => - seriesById.ContainsKey(e.SeriesId) && e.AirDateUtc.HasValue && e.AirDateUtc.Value <= graceCutoff; + void ExcludeEpisode(long seriesId, long episodeId, string name, SeekerExclusionReason reason) + { + seriesWithExcludedEpisodes.Add(seriesId); + if (excludedEpisodeIds.Add(episodeId)) + { + exclusions.Add(new SeekerExclusion(name, reason)); + } + } + + bool IsReleasedEpisodeOfCandidate(SearchableEpisode e) + { + if (!seriesById.TryGetValue(e.SeriesId, out SearchableSeries? series)) + { + return false; + } + + if (e.AirDateUtc.HasValue && e.AirDateUtc.Value <= graceCutoff) + { + return true; + } + + ExcludeEpisode(e.SeriesId, e.Id, $"{series.Title} S{e.SeasonNumber:D2}E{e.EpisodeNumber:D2}", + e.AirDateUtc is null || e.AirDateUtc.Value > now + ? SeekerExclusionReason.NotReleased + : SeekerExclusionReason.InGracePeriod); + return false; + } SonarrWantedEpisode ToWanted(SearchableEpisode e, SeekerSearchReason reason) => new() { @@ -382,6 +443,7 @@ public sealed class SeekerUnitEvaluator if (upgradesDisabledProfileIds.Contains(series.QualityProfileId)) { _logger.LogDebug("skip | upgrades disabled in profile | {Title} | {InstanceName}", entry.Title, arrInstance.Name); + ExcludeEpisode(entry.ExternalItemId, entry.EpisodeId, entry.Title, SeekerExclusionReason.UpgradesDisabled); continue; } @@ -400,6 +462,11 @@ public sealed class SeekerUnitEvaluator HashSet<(long SeriesId, int SeasonNumber)> finishedAnimeSeasons = await GetFinishedAnimeSeasonsAsync( arrInstance, seriesById, wanted, cancellationToken); + HashSet wantedSeriesIds = wanted.Select(w => w.SeriesId).ToHashSet(); + exclusions.AddRange(seriesById.Values + .Where(s => !wantedSeriesIds.Contains(s.Id) && !seriesWithExcludedEpisodes.Contains(s.Id)) + .Select(s => new SeekerExclusion(s.Title, SeekerExclusionReason.NothingToSearch))); + HashSet<(long SeriesId, long SeasonNumber)> queuedSeasons = activeQueueRecords .Where(r => r.SeriesId > 0) .Select(r => (r.SeriesId, r.SeasonNumber)) @@ -445,6 +512,10 @@ public sealed class SeekerUnitEvaluator return new SeekerEvaluation { AllLibraryIds = allLibraryIds, + ReleasedItems = releasedCount, + MonitoredItems = monitoredCount, + TagFilteredItems = seriesById.Count, + ItemExclusions = exclusions, Units = BuildSonarrUnits(wanted, seriesById, finishedAnimeSeasons, searchedSeasons, searchedEpisodeIds) .Select(u => u with { BlockedBy = GetBlockedBy(u) }) .ToList(), @@ -639,7 +710,10 @@ public sealed class SeekerUnitEvaluator return releaseDate is null || releaseDate.Value <= graceCutoff; } - private static bool PassesTagFilter( + /// + /// Returns why the tags keep the item out, or null when they let it through. A skip tag wins over an include tag. + /// + private static SeekerExclusionReason? GetTagExclusion( List itemTagIds, Dictionary tagsById, HashSet skipTags, @@ -652,9 +726,11 @@ public sealed class SeekerUnitEvaluator if (labels.Any(skipTags.Contains)) { - return false; + return SeekerExclusionReason.SkipTag; } - return includeTags.Count == 0 || labels.Any(includeTags.Contains); + return includeTags.Count == 0 || labels.Any(includeTags.Contains) + ? null + : SeekerExclusionReason.MissingIncludeTag; } } diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20261021141154_AddSeekerCycleFunnel.Designer.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20261021141154_AddSeekerCycleFunnel.Designer.cs new file mode 100644 index 00000000..58468377 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20261021141154_AddSeekerCycleFunnel.Designer.cs @@ -0,0 +1,882 @@ +// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Cleanuparr.Persistence.Postgres.Migrations.Events +{ + [DbContext(typeof(EventsContext))] + [Migration("20261021141154_AddSeekerCycleFunnel")] + partial class AddSeekerCycleFunnel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("events") + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CleanReason") + .HasColumnType("text") + .HasColumnName("clean_reason"); + + b.Property("CleanedCategory") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("cleaned_category"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("ContentKind") + .HasColumnType("text") + .HasColumnName("content_kind"); + + b.Property("CycleId") + .HasColumnType("uuid") + .HasColumnName("cycle_id"); + + b.Property("DeleteReason") + .HasColumnType("text") + .HasColumnName("delete_reason"); + + b.Property("DownloadClientId") + .HasColumnType("uuid") + .HasColumnName("download_client_id"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("event_type"); + + b.PrimitiveCollection>("FailedImportReasons") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("failed_import_reasons"); + + b.PrimitiveCollection>("GrabbedItems") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("grabbed_items"); + + b.Property("IsCategoryTag") + .HasColumnType("boolean") + .HasColumnName("is_category_tag"); + + b.Property("IsDryRun") + .HasColumnType("boolean") + .HasColumnName("is_dry_run"); + + b.Property("ItemHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("item_hash"); + + b.Property("ItemTitle") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("item_title"); + + b.Property("JobRunId") + .HasColumnType("uuid") + .HasColumnName("job_run_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("message"); + + b.Property("NewCategory") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("new_category"); + + b.Property("OldCategory") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("old_category"); + + b.Property("RemoveFromClient") + .HasColumnType("boolean") + .HasColumnName("remove_from_client"); + + b.Property("SearchReason") + .HasColumnType("text") + .HasColumnName("search_reason"); + + b.Property("SearchStatus") + .HasColumnType("text") + .HasColumnName("search_status"); + + b.Property("SearchType") + .HasColumnType("text") + .HasColumnName("search_type"); + + b.Property("SeedRatio") + .HasColumnType("double precision") + .HasColumnName("seed_ratio"); + + b.Property("SeedingTimeHours") + .HasColumnType("double precision") + .HasColumnName("seeding_time_hours"); + + b.Property("Severity") + .IsRequired() + .HasColumnType("text") + .HasColumnName("severity"); + + b.Property("StrikeCount") + .HasColumnType("integer") + .HasColumnName("strike_count"); + + b.Property("StrikeId") + .HasColumnType("uuid") + .HasColumnName("strike_id"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("TrackingId") + .HasColumnType("uuid") + .HasColumnName("tracking_id"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_events_arr_instance_id"); + + b.HasIndex("CycleId") + .HasDatabaseName("ix_events_cycle_id"); + + b.HasIndex("DeleteReason") + .HasDatabaseName("ix_events_delete_reason"); + + b.HasIndex("EventType") + .HasDatabaseName("ix_events_event_type"); + + b.HasIndex("JobRunId") + .HasDatabaseName("ix_events_job_run_id"); + + b.HasIndex("Severity") + .HasDatabaseName("ix_events_severity"); + + b.HasIndex("StrikeId") + .HasDatabaseName("ix_events_strike_id"); + + b.HasIndex("Timestamp") + .IsDescending() + .HasDatabaseName("ix_events_timestamp"); + + b.ToTable("events", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DownloadClientName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("download_client_name"); + + b.Property("DownloadClientType") + .HasColumnType("text") + .HasColumnName("download_client_type"); + + b.Property("InstanceType") + .HasColumnType("text") + .HasColumnName("instance_type"); + + b.Property("InstanceUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("instance_url"); + + b.Property("IsDryRun") + .HasColumnType("boolean") + .HasColumnName("is_dry_run"); + + b.Property("IsResolved") + .HasColumnType("boolean") + .HasColumnName("is_resolved"); + + b.Property("ItemHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("item_hash"); + + b.Property("ItemTitle") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("item_title"); + + b.Property("JobRunId") + .HasColumnType("uuid") + .HasColumnName("job_run_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("message"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasColumnType("text") + .HasColumnName("severity"); + + b.Property("StrikeCount") + .HasColumnType("integer") + .HasColumnName("strike_count"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_manual_events"); + + b.HasIndex("InstanceType") + .HasDatabaseName("ix_manual_events_instance_type"); + + b.HasIndex("IsResolved") + .HasDatabaseName("ix_manual_events_is_resolved"); + + b.HasIndex("JobRunId") + .HasDatabaseName("ix_manual_events_job_run_id"); + + b.HasIndex("Message") + .HasDatabaseName("ix_manual_events_message"); + + b.HasIndex("Severity") + .HasDatabaseName("ix_manual_events_severity"); + + b.HasIndex("Timestamp") + .IsDescending() + .HasDatabaseName("ix_manual_events_timestamp"); + + b.HasIndex("Type", "ItemHash") + .IsUnique() + .HasDatabaseName("ix_manual_events_type_item_hash") + .HasFilter("is_resolved = false"); + + b.ToTable("manual_events", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AirDateUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("air_date_utc"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentScore") + .HasColumnType("integer") + .HasColumnName("current_score"); + + b.Property("CutoffScore") + .HasColumnType("integer") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("bigint") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("bigint") + .HasColumnName("external_item_id"); + + b.Property("FileId") + .HasColumnType("bigint") + .HasColumnName("file_id"); + + b.Property("IsMonitored") + .HasColumnType("boolean") + .HasColumnName("is_monitored"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("item_type"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_synced_at"); + + b.Property("LastUpgradedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_upgraded_at"); + + b.Property("QualityProfileName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("quality_profile_name"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_entries"); + + b.HasIndex("LastUpgradedAt") + .HasDatabaseName("ix_custom_format_score_entries_last_upgraded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .IsUnique() + .HasDatabaseName("ix_custom_format_score_entries_arr_instance_id_external_item_i"); + + b.ToTable("custom_format_score_entries", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CutoffScore") + .HasColumnType("integer") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("bigint") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("bigint") + .HasColumnName("external_item_id"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("item_type"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("Score") + .HasColumnType("integer") + .HasColumnName("score"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_history"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("ix_custom_format_score_history_recorded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .HasDatabaseName("ix_custom_format_score_history_arr_instance_id_external_item_i"); + + b.ToTable("custom_format_score_history", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("DownloadId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("download_id"); + + b.Property("IsMarkedForRemoval") + .HasColumnType("boolean") + .HasColumnName("is_marked_for_removal"); + + b.Property("IsRemoved") + .HasColumnType("boolean") + .HasColumnName("is_removed"); + + b.Property("IsReturning") + .HasColumnType("boolean") + .HasColumnName("is_returning"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_download_items"); + + b.HasIndex("DownloadId") + .IsUnique() + .HasDatabaseName("ix_download_items_download_id"); + + b.ToTable("download_items", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.JobRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("text") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_job_runs"); + + b.HasIndex("StartedAt") + .IsDescending() + .HasDatabaseName("ix_job_runs_started_at"); + + b.HasIndex("Type") + .HasDatabaseName("ix_job_runs_type"); + + b.ToTable("job_runs", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ItemId") + .HasColumnType("bigint") + .HasColumnName("item_id"); + + b.Property("SearchType") + .HasColumnType("text") + .HasColumnName("search_type"); + + b.Property("SeriesId") + .HasColumnType("bigint") + .HasColumnName("series_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_search_queue"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_search_queue_arr_instance_id"); + + b.ToTable("search_queue", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CommandId") + .HasColumnType("bigint") + .HasColumnName("command_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("EpisodeId") + .HasColumnType("bigint") + .HasColumnName("episode_id"); + + b.Property("EventId") + .HasColumnType("uuid") + .HasColumnName("event_id"); + + b.Property("ExternalItemId") + .HasColumnType("bigint") + .HasColumnName("external_item_id"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("text") + .HasColumnName("item_title"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_seeker_command_trackers"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_command_trackers_arr_instance_id"); + + b.ToTable("seeker_command_trackers", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCycle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CompletionReason") + .HasColumnType("text") + .HasColumnName("completion_reason"); + + b.Property("EligibleAtStart") + .HasColumnType("integer") + .HasColumnName("eligible_at_start"); + + b.Property("EligibleNow") + .HasColumnType("integer") + .HasColumnName("eligible_now"); + + b.Property("FunnelCountedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("funnel_counted_at"); + + b.Property("GrabbedUnits") + .HasColumnType("integer") + .HasColumnName("grabbed_units"); + + b.Property("IsDryRun") + .HasColumnType("boolean") + .HasColumnName("is_dry_run"); + + b.Property("LibraryItems") + .HasColumnType("integer") + .HasColumnName("library_items"); + + b.Property("MonitoredItems") + .HasColumnType("integer") + .HasColumnName("monitored_items"); + + b.Property("NotifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("notified_at"); + + b.Property("QueuedUnits") + .HasColumnType("integer") + .HasColumnName("queued_units"); + + b.Property("RecentlyGrabbedUnits") + .HasColumnType("integer") + .HasColumnName("recently_grabbed_units"); + + b.Property("ReleasedItems") + .HasColumnType("integer") + .HasColumnName("released_items"); + + b.Property("RemainingUnits") + .HasColumnType("integer") + .HasColumnName("remaining_units"); + + b.Property("SearchedUnits") + .HasColumnType("integer") + .HasColumnName("searched_units"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("TagFilteredItems") + .HasColumnType("integer") + .HasColumnName("tag_filtered_items"); + + b.HasKey("Id") + .HasName("pk_seeker_cycles"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_cycles_arr_instance_id"); + + b.ToTable("seeker_cycles", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("ContentKind") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content_kind"); + + b.Property("CycleId") + .HasColumnType("uuid") + .HasColumnName("cycle_id"); + + b.Property("EpisodeId") + .HasColumnType("bigint") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("bigint") + .HasColumnName("external_item_id"); + + b.Property("Grabs") + .HasColumnType("integer") + .HasColumnName("grabs"); + + b.Property("IsDryRun") + .HasColumnType("boolean") + .HasColumnName("is_dry_run"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("text") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("item_type"); + + b.Property("LastOutcome") + .HasColumnType("text") + .HasColumnName("last_outcome"); + + b.Property("LastSearchedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_searched_at"); + + b.Property("SearchCount") + .HasColumnType("integer") + .HasColumnName("search_count"); + + b.Property("SeasonNumber") + .HasColumnType("integer") + .HasColumnName("season_number"); + + b.HasKey("Id") + .HasName("pk_seeker_history"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "EpisodeId", "IsDryRun") + .IsUnique() + .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_s"); + + b.ToTable("seeker_history", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DownloadItemId") + .HasColumnType("uuid") + .HasColumnName("download_item_id"); + + b.Property("IsDryRun") + .HasColumnType("boolean") + .HasColumnName("is_dry_run"); + + b.Property("JobRunId") + .HasColumnType("uuid") + .HasColumnName("job_run_id"); + + b.Property("LastDownloadedBytes") + .HasColumnType("bigint") + .HasColumnName("last_downloaded_bytes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_strikes"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_strikes_created_at"); + + b.HasIndex("JobRunId") + .HasDatabaseName("ix_strikes_job_run_id"); + + b.HasIndex("DownloadItemId", "Type") + .HasDatabaseName("ix_strikes_download_item_id_type"); + + b.ToTable("strikes", "events"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b => + { + b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun") + .WithMany("Events") + .HasForeignKey("JobRunId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_events_job_runs_job_run_id"); + + b.HasOne("Cleanuparr.Persistence.Models.State.Strike", "Strike") + .WithMany() + .HasForeignKey("StrikeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_events_strikes_strike_id"); + + b.Navigation("JobRun"); + + b.Navigation("Strike"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b => + { + b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun") + .WithMany("ManualEvents") + .HasForeignKey("JobRunId") + .HasConstraintName("fk_manual_events_job_runs_job_run_id"); + + b.Navigation("JobRun"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b => + { + b.HasOne("Cleanuparr.Persistence.Models.State.DownloadItem", "DownloadItem") + .WithMany("Strikes") + .HasForeignKey("DownloadItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_strikes_download_items_download_item_id"); + + b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun") + .WithMany("Strikes") + .HasForeignKey("JobRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_strikes_job_runs_job_run_id"); + + b.Navigation("DownloadItem"); + + b.Navigation("JobRun"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b => + { + b.Navigation("Strikes"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.JobRun", b => + { + b.Navigation("Events"); + + b.Navigation("ManualEvents"); + + b.Navigation("Strikes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20261021141154_AddSeekerCycleFunnel.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20261021141154_AddSeekerCycleFunnel.cs new file mode 100644 index 00000000..f116b6e6 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20261021141154_AddSeekerCycleFunnel.cs @@ -0,0 +1,109 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Postgres.Migrations.Events +{ + /// + public partial class AddSeekerCycleFunnel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "funnel_counted_at", + schema: "events", + table: "seeker_cycles", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "library_items", + schema: "events", + table: "seeker_cycles", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "monitored_items", + schema: "events", + table: "seeker_cycles", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "queued_units", + schema: "events", + table: "seeker_cycles", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "recently_grabbed_units", + schema: "events", + table: "seeker_cycles", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "released_items", + schema: "events", + table: "seeker_cycles", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "tag_filtered_items", + schema: "events", + table: "seeker_cycles", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "funnel_counted_at", + schema: "events", + table: "seeker_cycles"); + + migrationBuilder.DropColumn( + name: "library_items", + schema: "events", + table: "seeker_cycles"); + + migrationBuilder.DropColumn( + name: "monitored_items", + schema: "events", + table: "seeker_cycles"); + + migrationBuilder.DropColumn( + name: "queued_units", + schema: "events", + table: "seeker_cycles"); + + migrationBuilder.DropColumn( + name: "recently_grabbed_units", + schema: "events", + table: "seeker_cycles"); + + migrationBuilder.DropColumn( + name: "released_items", + schema: "events", + table: "seeker_cycles"); + + migrationBuilder.DropColumn( + name: "tag_filtered_items", + schema: "events", + table: "seeker_cycles"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/EventsContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/EventsContextModelSnapshot.cs index cf3f6f8f..71fa51f3 100644 --- a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/EventsContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/EventsContextModelSnapshot.cs @@ -630,6 +630,10 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Events .HasColumnType("integer") .HasColumnName("eligible_now"); + b.Property("FunnelCountedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("funnel_counted_at"); + b.Property("GrabbedUnits") .HasColumnType("integer") .HasColumnName("grabbed_units"); @@ -638,10 +642,30 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Events .HasColumnType("boolean") .HasColumnName("is_dry_run"); + b.Property("LibraryItems") + .HasColumnType("integer") + .HasColumnName("library_items"); + + b.Property("MonitoredItems") + .HasColumnType("integer") + .HasColumnName("monitored_items"); + b.Property("NotifiedAt") .HasColumnType("timestamp with time zone") .HasColumnName("notified_at"); + b.Property("QueuedUnits") + .HasColumnType("integer") + .HasColumnName("queued_units"); + + b.Property("RecentlyGrabbedUnits") + .HasColumnType("integer") + .HasColumnName("recently_grabbed_units"); + + b.Property("ReleasedItems") + .HasColumnType("integer") + .HasColumnName("released_items"); + b.Property("RemainingUnits") .HasColumnType("integer") .HasColumnName("remaining_units"); @@ -654,6 +678,10 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Events .HasColumnType("timestamp with time zone") .HasColumnName("started_at"); + b.Property("TagFilteredItems") + .HasColumnType("integer") + .HasColumnName("tag_filtered_items"); + b.HasKey("Id") .HasName("pk_seeker_cycles"); diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20261021141151_AddSeekerCycleFunnel.Designer.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20261021141151_AddSeekerCycleFunnel.Designer.cs new file mode 100644 index 00000000..9abf99f2 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20261021141151_AddSeekerCycleFunnel.Designer.cs @@ -0,0 +1,885 @@ +// +using System; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Events +{ + [DbContext(typeof(EventsContext))] + [Migration("20261021141151_AddSeekerCycleFunnel")] + partial class AddSeekerCycleFunnel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CleanReason") + .HasColumnType("TEXT") + .HasColumnName("clean_reason"); + + b.Property("CleanedCategory") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("cleaned_category"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("ContentKind") + .HasColumnType("TEXT") + .HasColumnName("content_kind"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + + b.Property("DeleteReason") + .HasColumnType("TEXT") + .HasColumnName("delete_reason"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("event_type"); + + b.PrimitiveCollection("FailedImportReasons") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_reasons"); + + b.PrimitiveCollection("GrabbedItems") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("grabbed_items"); + + b.Property("IsCategoryTag") + .HasColumnType("INTEGER") + .HasColumnName("is_category_tag"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("ItemHash") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("item_hash"); + + b.Property("ItemTitle") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("JobRunId") + .HasColumnType("TEXT") + .HasColumnName("job_run_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("NewCategory") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("new_category"); + + b.Property("OldCategory") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("old_category"); + + b.Property("RemoveFromClient") + .HasColumnType("INTEGER") + .HasColumnName("remove_from_client"); + + b.Property("SearchReason") + .HasColumnType("TEXT") + .HasColumnName("search_reason"); + + b.Property("SearchStatus") + .HasColumnType("TEXT") + .HasColumnName("search_status"); + + b.Property("SearchType") + .HasColumnType("TEXT") + .HasColumnName("search_type"); + + b.Property("SeedRatio") + .HasColumnType("REAL") + .HasColumnName("seed_ratio"); + + b.Property("SeedingTimeHours") + .HasColumnType("REAL") + .HasColumnName("seeding_time_hours"); + + b.Property("Severity") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("severity"); + + b.Property("StrikeCount") + .HasColumnType("INTEGER") + .HasColumnName("strike_count"); + + b.Property("StrikeId") + .HasColumnType("TEXT") + .HasColumnName("strike_id"); + + b.Property("Timestamp") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("timestamp"); + + b.Property("TrackingId") + .HasColumnType("TEXT") + .HasColumnName("tracking_id"); + + b.HasKey("Id") + .HasName("pk_events"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_events_arr_instance_id"); + + b.HasIndex("CycleId") + .HasDatabaseName("ix_events_cycle_id"); + + b.HasIndex("DeleteReason") + .HasDatabaseName("ix_events_delete_reason"); + + b.HasIndex("EventType") + .HasDatabaseName("ix_events_event_type"); + + b.HasIndex("JobRunId") + .HasDatabaseName("ix_events_job_run_id"); + + b.HasIndex("Severity") + .HasDatabaseName("ix_events_severity"); + + b.HasIndex("StrikeId") + .HasDatabaseName("ix_events_strike_id"); + + b.HasIndex("Timestamp") + .IsDescending() + .HasDatabaseName("ix_events_timestamp"); + + b.ToTable("events", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientName") + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("download_client_name"); + + b.Property("DownloadClientType") + .HasColumnType("TEXT") + .HasColumnName("download_client_type"); + + b.Property("InstanceType") + .HasColumnType("TEXT") + .HasColumnName("instance_type"); + + b.Property("InstanceUrl") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("instance_url"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("IsResolved") + .HasColumnType("INTEGER") + .HasColumnName("is_resolved"); + + b.Property("ItemHash") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("item_hash"); + + b.Property("ItemTitle") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("JobRunId") + .HasColumnType("TEXT") + .HasColumnName("job_run_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT") + .HasColumnName("message"); + + b.Property("ResolvedAt") + .HasColumnType("TEXT") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("severity"); + + b.Property("StrikeCount") + .HasColumnType("INTEGER") + .HasColumnName("strike_count"); + + b.Property("Timestamp") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("timestamp"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_manual_events"); + + b.HasIndex("InstanceType") + .HasDatabaseName("ix_manual_events_instance_type"); + + b.HasIndex("IsResolved") + .HasDatabaseName("ix_manual_events_is_resolved"); + + b.HasIndex("JobRunId") + .HasDatabaseName("ix_manual_events_job_run_id"); + + b.HasIndex("Message") + .HasDatabaseName("ix_manual_events_message"); + + b.HasIndex("Severity") + .HasDatabaseName("ix_manual_events_severity"); + + b.HasIndex("Timestamp") + .IsDescending() + .HasDatabaseName("ix_manual_events_timestamp"); + + b.HasIndex("Type", "ItemHash") + .IsUnique() + .HasDatabaseName("ix_manual_events_type_item_hash") + .HasFilter("\"is_resolved\" = 0"); + + b.ToTable("manual_events", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AirDateUtc") + .HasColumnType("TEXT") + .HasColumnName("air_date_utc"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentScore") + .HasColumnType("INTEGER") + .HasColumnName("current_score"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("FileId") + .HasColumnType("INTEGER") + .HasColumnName("file_id"); + + b.Property("IsMonitored") + .HasColumnType("INTEGER") + .HasColumnName("is_monitored"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSyncedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_synced_at"); + + b.Property("LastUpgradedAt") + .HasColumnType("TEXT") + .HasColumnName("last_upgraded_at"); + + b.Property("QualityProfileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("quality_profile_name"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_entries"); + + b.HasIndex("LastUpgradedAt") + .HasDatabaseName("ix_custom_format_score_entries_last_upgraded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .IsUnique() + .HasDatabaseName("ix_custom_format_score_entries_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_entries", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("RecordedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("recorded_at"); + + b.Property("Score") + .HasColumnType("INTEGER") + .HasColumnName("score"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_history"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("ix_custom_format_score_history_recorded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .HasDatabaseName("ix_custom_format_score_history_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("download_id"); + + b.Property("IsMarkedForRemoval") + .HasColumnType("INTEGER") + .HasColumnName("is_marked_for_removal"); + + b.Property("IsRemoved") + .HasColumnType("INTEGER") + .HasColumnName("is_removed"); + + b.Property("IsReturning") + .HasColumnType("INTEGER") + .HasColumnName("is_returning"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_download_items"); + + b.HasIndex("DownloadId") + .IsUnique() + .HasDatabaseName("ix_download_items_download_id"); + + b.ToTable("download_items", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.JobRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_job_runs"); + + b.HasIndex("StartedAt") + .IsDescending() + .HasDatabaseName("ix_job_runs_started_at"); + + b.HasIndex("Type") + .HasDatabaseName("ix_job_runs_type"); + + b.ToTable("job_runs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ItemId") + .HasColumnType("INTEGER") + .HasColumnName("item_id"); + + b.Property("SearchType") + .HasColumnType("TEXT") + .HasColumnName("search_type"); + + b.Property("SeriesId") + .HasColumnType("INTEGER") + .HasColumnName("series_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_search_queue"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_search_queue_arr_instance_id"); + + b.ToTable("search_queue", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CommandId") + .HasColumnType("INTEGER") + .HasColumnName("command_id"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("EventId") + .HasColumnType("TEXT") + .HasColumnName("event_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_seeker_command_trackers"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_command_trackers_arr_instance_id"); + + b.ToTable("seeker_command_trackers", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCycle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CompletedAt") + .HasColumnType("TEXT") + .HasColumnName("completed_at"); + + b.Property("CompletionReason") + .HasColumnType("TEXT") + .HasColumnName("completion_reason"); + + b.Property("EligibleAtStart") + .HasColumnType("INTEGER") + .HasColumnName("eligible_at_start"); + + b.Property("EligibleNow") + .HasColumnType("INTEGER") + .HasColumnName("eligible_now"); + + b.Property("FunnelCountedAt") + .HasColumnType("TEXT") + .HasColumnName("funnel_counted_at"); + + b.Property("GrabbedUnits") + .HasColumnType("INTEGER") + .HasColumnName("grabbed_units"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("LibraryItems") + .HasColumnType("INTEGER") + .HasColumnName("library_items"); + + b.Property("MonitoredItems") + .HasColumnType("INTEGER") + .HasColumnName("monitored_items"); + + b.Property("NotifiedAt") + .HasColumnType("TEXT") + .HasColumnName("notified_at"); + + b.Property("QueuedUnits") + .HasColumnType("INTEGER") + .HasColumnName("queued_units"); + + b.Property("RecentlyGrabbedUnits") + .HasColumnType("INTEGER") + .HasColumnName("recently_grabbed_units"); + + b.Property("ReleasedItems") + .HasColumnType("INTEGER") + .HasColumnName("released_items"); + + b.Property("RemainingUnits") + .HasColumnType("INTEGER") + .HasColumnName("remaining_units"); + + b.Property("SearchedUnits") + .HasColumnType("INTEGER") + .HasColumnName("searched_units"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("TagFilteredItems") + .HasColumnType("INTEGER") + .HasColumnName("tag_filtered_items"); + + b.HasKey("Id") + .HasName("pk_seeker_cycles"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_cycles_arr_instance_id"); + + b.ToTable("seeker_cycles", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("ContentKind") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("content_kind"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("Grabs") + .HasColumnType("INTEGER") + .HasColumnName("grabs"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastOutcome") + .HasColumnType("TEXT") + .HasColumnName("last_outcome"); + + b.Property("LastSearchedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_searched_at"); + + b.Property("SearchCount") + .HasColumnType("INTEGER") + .HasColumnName("search_count"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.HasKey("Id") + .HasName("pk_seeker_history"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "EpisodeId", "IsDryRun") + .IsUnique() + .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_episode_id_is_dry_run"); + + b.ToTable("seeker_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DownloadItemId") + .HasColumnType("TEXT") + .HasColumnName("download_item_id"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("JobRunId") + .HasColumnType("TEXT") + .HasColumnName("job_run_id"); + + b.Property("LastDownloadedBytes") + .HasColumnType("INTEGER") + .HasColumnName("last_downloaded_bytes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_strikes"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_strikes_created_at"); + + b.HasIndex("JobRunId") + .HasDatabaseName("ix_strikes_job_run_id"); + + b.HasIndex("DownloadItemId", "Type") + .HasDatabaseName("ix_strikes_download_item_id_type"); + + b.ToTable("strikes", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.AppEvent", b => + { + b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun") + .WithMany("Events") + .HasForeignKey("JobRunId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_events_job_runs_job_run_id"); + + b.HasOne("Cleanuparr.Persistence.Models.State.Strike", "Strike") + .WithMany() + .HasForeignKey("StrikeId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_events_strikes_strike_id"); + + b.Navigation("JobRun"); + + b.Navigation("Strike"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Events.ManualEvent", b => + { + b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun") + .WithMany("ManualEvents") + .HasForeignKey("JobRunId") + .HasConstraintName("fk_manual_events_job_runs_job_run_id"); + + b.Navigation("JobRun"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.Strike", b => + { + b.HasOne("Cleanuparr.Persistence.Models.State.DownloadItem", "DownloadItem") + .WithMany("Strikes") + .HasForeignKey("DownloadItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_strikes_download_items_download_item_id"); + + b.HasOne("Cleanuparr.Persistence.Models.State.JobRun", "JobRun") + .WithMany("Strikes") + .HasForeignKey("JobRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_strikes_job_runs_job_run_id"); + + b.Navigation("DownloadItem"); + + b.Navigation("JobRun"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.DownloadItem", b => + { + b.Navigation("Strikes"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.JobRun", b => + { + b.Navigation("Events"); + + b.Navigation("ManualEvents"); + + b.Navigation("Strikes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20261021141151_AddSeekerCycleFunnel.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20261021141151_AddSeekerCycleFunnel.cs new file mode 100644 index 00000000..19e25dc3 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20261021141151_AddSeekerCycleFunnel.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Events +{ + /// + public partial class AddSeekerCycleFunnel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "funnel_counted_at", + table: "seeker_cycles", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "library_items", + table: "seeker_cycles", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "monitored_items", + table: "seeker_cycles", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "queued_units", + table: "seeker_cycles", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "recently_grabbed_units", + table: "seeker_cycles", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "released_items", + table: "seeker_cycles", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "tag_filtered_items", + table: "seeker_cycles", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "funnel_counted_at", + table: "seeker_cycles"); + + migrationBuilder.DropColumn( + name: "library_items", + table: "seeker_cycles"); + + migrationBuilder.DropColumn( + name: "monitored_items", + table: "seeker_cycles"); + + migrationBuilder.DropColumn( + name: "queued_units", + table: "seeker_cycles"); + + migrationBuilder.DropColumn( + name: "recently_grabbed_units", + table: "seeker_cycles"); + + migrationBuilder.DropColumn( + name: "released_items", + table: "seeker_cycles"); + + migrationBuilder.DropColumn( + name: "tag_filtered_items", + table: "seeker_cycles"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/EventsContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/EventsContextModelSnapshot.cs index 2765c44e..1f7a9d4c 100644 --- a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/EventsContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/EventsContextModelSnapshot.cs @@ -630,6 +630,10 @@ namespace Cleanuparr.Persistence.Migrations.Events .HasColumnType("INTEGER") .HasColumnName("eligible_now"); + b.Property("FunnelCountedAt") + .HasColumnType("TEXT") + .HasColumnName("funnel_counted_at"); + b.Property("GrabbedUnits") .HasColumnType("INTEGER") .HasColumnName("grabbed_units"); @@ -638,10 +642,30 @@ namespace Cleanuparr.Persistence.Migrations.Events .HasColumnType("INTEGER") .HasColumnName("is_dry_run"); + b.Property("LibraryItems") + .HasColumnType("INTEGER") + .HasColumnName("library_items"); + + b.Property("MonitoredItems") + .HasColumnType("INTEGER") + .HasColumnName("monitored_items"); + b.Property("NotifiedAt") .HasColumnType("TEXT") .HasColumnName("notified_at"); + b.Property("QueuedUnits") + .HasColumnType("INTEGER") + .HasColumnName("queued_units"); + + b.Property("RecentlyGrabbedUnits") + .HasColumnType("INTEGER") + .HasColumnName("recently_grabbed_units"); + + b.Property("ReleasedItems") + .HasColumnType("INTEGER") + .HasColumnName("released_items"); + b.Property("RemainingUnits") .HasColumnType("INTEGER") .HasColumnName("remaining_units"); @@ -655,6 +679,10 @@ namespace Cleanuparr.Persistence.Migrations.Events .HasColumnType("TEXT") .HasColumnName("started_at"); + b.Property("TagFilteredItems") + .HasColumnType("INTEGER") + .HasColumnName("tag_filtered_items"); + b.HasKey("Id") .HasName("pk_seeker_cycles"); diff --git a/code/backend/Cleanuparr.Persistence/Models/State/SeekerCycle.cs b/code/backend/Cleanuparr.Persistence/Models/State/SeekerCycle.cs index bfbc4ec9..49fb6480 100644 --- a/code/backend/Cleanuparr.Persistence/Models/State/SeekerCycle.cs +++ b/code/backend/Cleanuparr.Persistence/Models/State/SeekerCycle.cs @@ -64,6 +64,41 @@ public sealed record SeekerCycle /// public int GrabbedUnits { get; set; } + /// + /// Movies or series in the library on the latest run + /// + public int LibraryItems { get; set; } + + /// + /// Library items released and past the grace period on the latest run + /// + public int ReleasedItems { get; set; } + + /// + /// Released items that passed the monitored filter on the latest run + /// + public int MonitoredItems { get; set; } + + /// + /// Monitored items that passed the tag filters on the latest run + /// + public int TagFilteredItems { get; set; } + + /// + /// Eligible units on the latest run that wait on a queued download + /// + public int QueuedUnits { get; set; } + + /// + /// Eligible units on the latest run that wait on a recent grab + /// + public int RecentlyGrabbedUnits { get; set; } + + /// + /// When the latest run counted the funnel. Null until a run does. + /// + public DateTimeOffset? FunnelCountedAt { get; set; } + /// /// Whether the cycle ran in dry run mode ///