From bba4e943bcc11a8b25b072df3d1058bc3cea0a09 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Tue, 8 Sep 2026 13:14:46 +0300 Subject: [PATCH] Fix Seeker not reporting an episode grab (#776) --- .../Jobs/SeekerCommandMonitorTests.cs | 141 ++++ .../Features/Jobs/SeekerTests.cs | 121 +++ .../Features/Jobs/Seeker.cs | 21 +- .../Features/Jobs/SeekerCommandMonitor.cs | 21 +- ...pisodeIdToSeekerCommandTracker.Designer.cs | 765 +++++++++++++++++ ...1959_AddEpisodeIdToSeekerCommandTracker.cs | 31 + .../Events/EventsContextModelSnapshot.cs | 4 + ...pisodeIdToSeekerCommandTracker.Designer.cs | 767 ++++++++++++++++++ ...1938_AddEpisodeIdToSeekerCommandTracker.cs | 29 + .../Events/EventsContextModelSnapshot.cs | 4 + .../Models/State/SeekerCommandTracker.cs | 8 +- .../seeker-replacement-grab.api.spec.ts | 101 +++ 12 files changed, 2006 insertions(+), 7 deletions(-) create mode 100644 code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20260908071959_AddEpisodeIdToSeekerCommandTracker.Designer.cs create mode 100644 code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20260908071959_AddEpisodeIdToSeekerCommandTracker.cs create mode 100644 code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20260908071938_AddEpisodeIdToSeekerCommandTracker.Designer.cs create mode 100644 code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20260908071938_AddEpisodeIdToSeekerCommandTracker.cs create mode 100644 e2e/tests/live-arr-fast/seeker-replacement-grab.api.spec.ts diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerCommandMonitorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerCommandMonitorTests.cs index 3417f996..9ebd41af 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerCommandMonitorTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerCommandMonitorTests.cs @@ -808,6 +808,147 @@ public class SeekerCommandMonitorTests : IAsyncDisposable _timeProvider.GetUtcNow() - TimeSpan.FromMinutes(60)); } + [Fact] + public async Task Reports_the_grab_of_a_sonarr_episode_search() + { + // Arrange + ArrInstance sonarrInstance = TestDataContextFactory.AddSonarrInstance(_dataContext); + + // Act + List? grabbedItems = await CaptureGrabbedItemsAsync( + Tracker(sonarrInstance.Id, externalItemId: 42, episodeId: 9001, itemTitle: "Example Show - S03E04"), + new QueueRecord { Id = 1, SeriesId = 42, EpisodeId = 9001, SeasonNumber = 3, Title = "Example.Show.S03E04.1080p", DownloadId = "EP9001", Protocol = "torrent", Status = "downloading" }); + + // Assert + grabbedItems.ShouldNotBeNull(); + grabbedItems!.ShouldBe(["Example.Show.S03E04.1080p"]); + } + + [Fact] + public async Task Ignores_another_episode_of_the_same_series() + { + // Arrange + ArrInstance sonarrInstance = TestDataContextFactory.AddSonarrInstance(_dataContext); + + // Act + List? grabbedItems = await CaptureGrabbedItemsAsync( + Tracker(sonarrInstance.Id, externalItemId: 42, episodeId: 9001, itemTitle: "Example Show - S03E04"), + new QueueRecord { Id = 1, SeriesId = 42, EpisodeId = 9001, SeasonNumber = 3, Title = "Example.Show.S03E04.1080p", DownloadId = "EP9001", Protocol = "torrent", Status = "downloading" }, + new QueueRecord { Id = 2, SeriesId = 42, EpisodeId = 9002, SeasonNumber = 3, Title = "Example.Show.S03E05.1080p", DownloadId = "EP9002", Protocol = "torrent", Status = "downloading" }); + + // Assert + grabbedItems.ShouldNotBeNull(); + grabbedItems!.ShouldBe(["Example.Show.S03E04.1080p"]); + } + + [Fact] + public async Task Ignores_another_season_of_a_specials_search() + { + // Arrange + ArrInstance sonarrInstance = TestDataContextFactory.AddSonarrInstance(_dataContext); + + // Act + List? grabbedItems = await CaptureGrabbedItemsAsync( + Tracker(sonarrInstance.Id, externalItemId: 42, seasonNumber: 0, itemTitle: "Example Show S00"), + new QueueRecord { Id = 1, SeriesId = 42, EpisodeId = 9001, SeasonNumber = 3, Title = "Example.Show.S03E04.1080p", DownloadId = "EP9001", Protocol = "torrent", Status = "downloading" }); + + // Assert + grabbedItems.ShouldBeNull(); + } + + [Fact] + public async Task Reports_the_grab_of_a_whisparr_v3_search() + { + // Arrange + ArrInstance whisparrInstance = TestDataContextFactory.AddWhisparrInstance(_dataContext, version: 3); + + // Act + List? grabbedItems = await CaptureGrabbedItemsAsync( + Tracker(whisparrInstance.Id, externalItemId: 55, itemTitle: "Example Scene"), + new QueueRecord { Id = 1, MovieId = 55, Title = "Example.Scene.1080p", DownloadId = "MV55", Protocol = "torrent", Status = "downloading" }); + + // Assert + grabbedItems.ShouldNotBeNull(); + grabbedItems!.ShouldBe(["Example.Scene.1080p"]); + } + + [Fact] + public async Task Reports_the_grab_of_a_lidarr_search() + { + // Arrange + ArrInstance lidarrInstance = TestDataContextFactory.AddLidarrInstance(_dataContext); + + // Act + List? grabbedItems = await CaptureGrabbedItemsAsync( + Tracker(lidarrInstance.Id, externalItemId: 77, itemTitle: "Example Album"), + new QueueRecord { Id = 1, AlbumId = 77, Title = "Example.Album.FLAC", DownloadId = "AL77", Protocol = "torrent", Status = "downloading" }); + + // Assert + grabbedItems.ShouldNotBeNull(); + grabbedItems!.ShouldBe(["Example.Album.FLAC"]); + } + + [Fact] + public async Task Reports_the_grab_of_a_readarr_search() + { + // Arrange + ArrInstance readarrInstance = TestDataContextFactory.AddReadarrInstance(_dataContext); + + // Act + List? grabbedItems = await CaptureGrabbedItemsAsync( + Tracker(readarrInstance.Id, externalItemId: 88, itemTitle: "Example Book"), + new QueueRecord { Id = 1, BookId = 88, Title = "Example.Book.EPUB", DownloadId = "BK88", Protocol = "torrent", Status = "downloading" }); + + // Assert + grabbedItems.ShouldNotBeNull(); + grabbedItems!.ShouldBe(["Example.Book.EPUB"]); + } + + private SeekerCommandTracker Tracker( + Guid arrInstanceId, + long externalItemId, + long episodeId = 0, + int seasonNumber = 0, + string itemTitle = "Test Item") => + new() + { + ArrInstanceId = arrInstanceId, + CommandId = 1, + EventId = Guid.NewGuid(), + ExternalItemId = externalItemId, + EpisodeId = episodeId, + ItemTitle = itemTitle, + SeasonNumber = seasonNumber, + Status = SearchCommandStatus.Pending, + CreatedAt = _timeProvider.GetUtcNow().UtcDateTime, + }; + + /// + /// Runs one monitor cycle over a completed command and returns the titles it attributed. + /// + private async Task?> CaptureGrabbedItemsAsync(SeekerCommandTracker tracker, params QueueRecord[] records) + { + _eventsContext.SeekerCommandTrackers.Add(tracker); + await _dataContext.SaveChangesAsync(); + await _eventsContext.SaveChangesAsync(); + + StubCommandState(ArrCommandState.Completed, tracker.CommandId); + + _arrClient.GetQueueItemsAsync(Arg.Any(), Arg.Any()) + .Returns(new QueueListResponse { TotalRecords = records.Length, Records = [.. records] }); + + TaskCompletionSource?> publishTcs = new(); + _eventPublisher.PublishSearchCompleted( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()) + .Returns(Task.CompletedTask) + .AndDoes(ci => publishTcs.TrySetResult(ci.ArgAt?>(4))); + + await _sut.StartAsync(_cts.Token); + _timeProvider.Advance(TimeSpan.FromSeconds(11)); + + return await publishTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + private void StubCommandState(ArrCommandState state, long commandId = 1) { _arrClient.GetCommandsAsync(Arg.Any()) diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs index 9b54e308..5d8c247e 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/SeekerTests.cs @@ -4107,6 +4107,127 @@ public class SeekerTests : IDisposable capturedSearchItem.SearchType.ShouldBe(SeriesSearchType.Season); } + [Fact] + public async Task ExecuteAsync_SonarrEpisodeReplacement_TracksSeriesAndEpisode() + { + // Arrange — an episode replacement carries the episode id in ItemId + ArrInstance sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.EventsContext.SearchQueue.Add(new SearchQueueItem + { + ArrInstanceId = sonarrInstance.Id, + ItemId = 9001, + SeriesId = 42, + SearchType = "Episode", + Title = "Example Show - S03E04", + CreatedAt = DateTime.UtcNow + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + IArrClient mockArrClient = Substitute.For(); + mockArrClient + .SearchItemAsync(Arg.Any(), Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert — the tracker holds the series for the queue lookup and the episode for the match + SeekerCommandTracker? tracker = await _fixture.EventsContext.SeekerCommandTrackers + .FirstOrDefaultAsync(t => t.ArrInstanceId == sonarrInstance.Id); + tracker.ShouldNotBeNull(); + tracker.ExternalItemId.ShouldBe(42); + tracker.EpisodeId.ShouldBe(9001); + tracker.SeasonNumber.ShouldBe(0); + } + + [Fact] + public async Task ExecuteAsync_SonarrSeasonReplacement_TracksSeriesAndSeason() + { + // Arrange — a season replacement carries the season number in ItemId + ArrInstance sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.EventsContext.SearchQueue.Add(new SearchQueueItem + { + ArrInstanceId = sonarrInstance.Id, + ItemId = 3, + SeriesId = 42, + SearchType = "Season", + Title = "Example Show S03", + CreatedAt = DateTime.UtcNow + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + IArrClient mockArrClient = Substitute.For(); + mockArrClient + .SearchItemAsync(Arg.Any(), Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + SeekerCommandTracker? tracker = await _fixture.EventsContext.SeekerCommandTrackers + .FirstOrDefaultAsync(t => t.ArrInstanceId == sonarrInstance.Id); + tracker.ShouldNotBeNull(); + tracker.ExternalItemId.ShouldBe(42); + tracker.EpisodeId.ShouldBe(0); + tracker.SeasonNumber.ShouldBe(3); + } + + [Fact] + public async Task ExecuteAsync_RadarrReplacement_TracksTheMovie() + { + // Arrange + ArrInstance radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.EventsContext.SearchQueue.Add(new SearchQueueItem + { + ArrInstanceId = radarrInstance.Id, + ItemId = 55, + Title = "Example Movie", + CreatedAt = DateTime.UtcNow + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + IArrClient mockArrClient = Substitute.For(); + mockArrClient + .SearchItemAsync(Arg.Any(), Arg.Any()) + .Returns(100L); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Radarr, Arg.Any()) + .Returns(mockArrClient); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + SeekerCommandTracker? tracker = await _fixture.EventsContext.SeekerCommandTrackers + .FirstOrDefaultAsync(t => t.ArrInstanceId == radarrInstance.Id); + tracker.ShouldNotBeNull(); + tracker.ExternalItemId.ShouldBe(55); + tracker.EpisodeId.ShouldBe(0); + tracker.SeasonNumber.ShouldBe(0); + } + [Fact] public async Task ExecuteAsync_ReplacementSearchFails_StillDequesItem() { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs index 6ff8e3a4..9991e710 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/Seeker.cs @@ -179,7 +179,10 @@ public sealed class Seeker : IHandler if (!isDryRun) { - await SaveCommandTrackerAsync(commandId, eventId, arrInstance.Id, arrInstance.ArrConfig.Type, item.ItemId, item.Title); + (long externalItemId, long episodeId, int seasonNumber) = ResolveTrackedIds(searchItem); + + await SaveCommandTrackerAsync(commandId, eventId, arrInstance.Id, arrInstance.ArrConfig.Type, + externalItemId, item.Title, seasonNumber, episodeId); } _logger.LogInformation("Replacement search triggered for '{Title}' on {InstanceName}", @@ -215,6 +218,18 @@ public sealed class Seeker : IHandler return new SearchItem { Id = item.ItemId }; } + /// + /// Splits a search item into the ids the SeekerCommandMonitor matches queue records against. + /// An episode search targets the episode, every other search targets its library item. + /// + private static (long ExternalItemId, long EpisodeId, int SeasonNumber) ResolveTrackedIds(SearchItem searchItem) => + searchItem switch + { + SeriesSearchItem { SearchType: SeriesSearchType.Episode } series => (series.SeriesId, series.Id, 0), + SeriesSearchItem { SearchType: SeriesSearchType.Season } series => (series.SeriesId, 0, (int)series.Id), + _ => (searchItem.Id, 0, 0), + }; + private async Task ProcessProactiveSearchAsync(SeekerConfig config, bool isDryRun, CancellationToken cancellationToken) { List instanceConfigs = await _dataContext.SeekerInstanceConfigs @@ -992,7 +1007,8 @@ public sealed class Seeker : IHandler InstanceType instanceType, long externalItemId, string itemTitle, - int seasonNumber = 0) + int seasonNumber = 0, + long episodeId = 0) { _eventsContext.SeekerCommandTrackers.Add(new SeekerCommandTracker { @@ -1000,6 +1016,7 @@ public sealed class Seeker : IHandler CommandId = commandId, EventId = eventId, ExternalItemId = externalItemId, + EpisodeId = episodeId, ItemTitle = itemTitle, SeasonNumber = seasonNumber, CreatedAt = _timeProvider.GetUtcNow(), diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/SeekerCommandMonitor.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/SeekerCommandMonitor.cs index ac0e06d8..770aab5f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/SeekerCommandMonitor.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/SeekerCommandMonitor.cs @@ -402,10 +402,7 @@ public class SeekerCommandMonitor : BackgroundService IReadOnlyList queue) { List grabbedTitles = queue - .Where(r => arrInstance.ArrConfig.Type == InstanceType.Radarr - ? r.MovieId == tracker.ExternalItemId - : r.SeriesId == tracker.ExternalItemId - && (tracker.SeasonNumber == 0 || r.SeasonNumber == tracker.SeasonNumber)) + .Where(r => MatchesTracker(r, tracker, arrInstance)) .Where(r => !string.IsNullOrEmpty(r.DownloadId)) .GroupBy(r => r.DownloadId) .Select(g => g.First()) @@ -421,4 +418,20 @@ public class SeekerCommandMonitor : BackgroundService return grabbedTitles.Count > 0 ? grabbedTitles : null; } + + /// + /// Tells whether a queue record holds what the search asked for. + /// Each arr fills its own id on the record, so match the tracker against that id. + /// + private static bool MatchesTracker(QueueRecord record, SeekerCommandTracker tracker, ArrInstance arrInstance) => + arrInstance.ArrConfig.Type switch + { + InstanceType.Radarr => record.MovieId == tracker.ExternalItemId, + InstanceType.Whisparr when arrInstance.Version is 3 => record.MovieId == tracker.ExternalItemId, + InstanceType.Lidarr => record.AlbumId == tracker.ExternalItemId, + InstanceType.Readarr => record.BookId == tracker.ExternalItemId, + _ => tracker.EpisodeId > 0 + ? record.EpisodeId == tracker.EpisodeId + : record.SeriesId == tracker.ExternalItemId && record.SeasonNumber == tracker.SeasonNumber, + }; } diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20260908071959_AddEpisodeIdToSeekerCommandTracker.Designer.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20260908071959_AddEpisodeIdToSeekerCommandTracker.Designer.cs new file mode 100644 index 00000000..c70bd722 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20260908071959_AddEpisodeIdToSeekerCommandTracker.Designer.cs @@ -0,0 +1,765 @@ +// +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("20260908071959_AddEpisodeIdToSeekerCommandTracker")] + partial class AddEpisodeIdToSeekerCommandTracker + { + /// + 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("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("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("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.SeekerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("uuid") + .HasColumnName("arr_instance_id"); + + b.Property("CycleId") + .HasColumnType("uuid") + .HasColumnName("cycle_id"); + + b.Property("ExternalItemId") + .HasColumnType("bigint") + .HasColumnName("external_item_id"); + + 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("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", "CycleId") + .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/20260908071959_AddEpisodeIdToSeekerCommandTracker.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20260908071959_AddEpisodeIdToSeekerCommandTracker.cs new file mode 100644 index 00000000..0849a74d --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/20260908071959_AddEpisodeIdToSeekerCommandTracker.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Postgres.Migrations.Events +{ + /// + public partial class AddEpisodeIdToSeekerCommandTracker : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "episode_id", + schema: "events", + table: "seeker_command_trackers", + type: "bigint", + nullable: false, + defaultValue: 0L); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "episode_id", + schema: "events", + table: "seeker_command_trackers"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/EventsContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/EventsContextModelSnapshot.cs index 4920fe40..e12f6550 100644 --- a/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/EventsContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence.Postgres/Migrations/Events/EventsContextModelSnapshot.cs @@ -556,6 +556,10 @@ namespace Cleanuparr.Persistence.Postgres.Migrations.Events .HasColumnType("timestamp with time zone") .HasColumnName("created_at"); + b.Property("EpisodeId") + .HasColumnType("bigint") + .HasColumnName("episode_id"); + b.Property("EventId") .HasColumnType("uuid") .HasColumnName("event_id"); diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20260908071938_AddEpisodeIdToSeekerCommandTracker.Designer.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20260908071938_AddEpisodeIdToSeekerCommandTracker.Designer.cs new file mode 100644 index 00000000..0f21fc32 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20260908071938_AddEpisodeIdToSeekerCommandTracker.Designer.cs @@ -0,0 +1,767 @@ +// +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("20260908071938_AddEpisodeIdToSeekerCommandTracker")] + partial class AddEpisodeIdToSeekerCommandTracker + { + /// + 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("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("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("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.SeekerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + 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("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", "CycleId") + .IsUnique() + .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_cycle_id"); + + 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/20260908071938_AddEpisodeIdToSeekerCommandTracker.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20260908071938_AddEpisodeIdToSeekerCommandTracker.cs new file mode 100644 index 00000000..0b158d13 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/20260908071938_AddEpisodeIdToSeekerCommandTracker.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Events +{ + /// + public partial class AddEpisodeIdToSeekerCommandTracker : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "episode_id", + table: "seeker_command_trackers", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "episode_id", + table: "seeker_command_trackers"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/EventsContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/EventsContextModelSnapshot.cs index c804a1c0..c2a03108 100644 --- a/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/EventsContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence.Sqlite/Migrations/Events/EventsContextModelSnapshot.cs @@ -556,6 +556,10 @@ namespace Cleanuparr.Persistence.Migrations.Events .HasColumnType("TEXT") .HasColumnName("created_at"); + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + b.Property("EventId") .HasColumnType("TEXT") .HasColumnName("event_id"); diff --git a/code/backend/Cleanuparr.Persistence/Models/State/SeekerCommandTracker.cs b/code/backend/Cleanuparr.Persistence/Models/State/SeekerCommandTracker.cs index 8fb020a6..cac95215 100644 --- a/code/backend/Cleanuparr.Persistence/Models/State/SeekerCommandTracker.cs +++ b/code/backend/Cleanuparr.Persistence/Models/State/SeekerCommandTracker.cs @@ -31,10 +31,16 @@ public sealed record SeekerCommandTracker public Guid EventId { get; set; } /// - /// The external item ID that was searched (movieId or seriesId) + /// The library-level item that was searched (seriesId, movieId, albumId or bookId) /// public long ExternalItemId { get; set; } + /// + /// For Sonarr episode-level searches, the episode that was searched. + /// 0 when the search targeted a whole season, movie, album or book. + /// + public long EpisodeId { get; set; } + /// /// Display name of the item that was searched /// diff --git a/e2e/tests/live-arr-fast/seeker-replacement-grab.api.spec.ts b/e2e/tests/live-arr-fast/seeker-replacement-grab.api.spec.ts new file mode 100644 index 00000000..a919e6c7 --- /dev/null +++ b/e2e/tests/live-arr-fast/seeker-replacement-grab.api.spec.ts @@ -0,0 +1,101 @@ +import { test, expect } from '../fixtures/base'; +import { indexerMock } from '../helpers/live-arr'; +import { + RUN_TAG, + SONARR, + TRANSITION_TIMEOUT, + TRIGGERED_SEARCH_TIMEOUT, + arrangeInstance, + arrangeQueueCleaner, + createStallRule, + listSearchEvents, + resetLiveArrState, + teardownInstances, + teardownQueueCleaner, + triggerSeeker, + waitForArrQueue, +} from '../helpers/seeker-live'; +import { grabbableRelease } from '../helpers/mocks/torznab-stubs'; + +/** + * What a replacement search records once the arr grabs for it. + * + * Cover for #774, so it deliberately avoids S01E01. + * The seed gives that episode id 1, the same number as its series, + * and a series id then reads the same as an episode id. + * S01E02 has episode id 2, which tells the two apart. + */ + +const SETTLE_MS = 30_000; + +/** The rule's floor is above one, so the cleaner needs this many runs to remove. */ +const MAX_STRIKES = 3; + +const EPISODE = 'Agatha.All.Along.S01E02.1080p.WEB-DL.DDP5.1.H.264'; + +test.describe('Seeker replacement grabs', () => { + test.beforeEach(async () => { + await resetLiveArrState(); + }); + + test.afterEach(async ({ api }) => { + await teardownQueueCleaner(api); + await teardownInstances(api); + await resetLiveArrState(); + }); + + test('records the Sonarr grab on the replacement search event', async ({ api }) => { + test.setTimeout(300_000); + + const first = grabbableRelease(SONARR.searchMode, `${EPISODE}-E2EA${RUN_TAG}`, SONARR.category); + await indexerMock.stubMany(first.mappings); + + await arrangeQueueCleaner(api); + + const instanceId = await arrangeInstance(api, SONARR); + await triggerSeeker(api); + await waitForArrQueue(SONARR.arr, first.downloadId, TRIGGERED_SEARCH_TIMEOUT); + + await createStallRule(api, MAX_STRIKES); + + // One strike per run, and the removal happens on the run that reaches the limit. + for (let strike = 0; strike <= MAX_STRIKES; strike++) { + const triggered = await api.jobs.trigger('QueueCleaner'); + expect(triggered.status).toBeLessThan(300); + await new Promise((resolve) => setTimeout(resolve, 3_000)); + } + + await expect + .poll( + async () => { + await SONARR.arr.refreshMonitoredDownloads(); + return (await SONARR.arr.queue()).some((record) => record.downloadId === first.downloadId); + }, + { timeout: SETTLE_MS }, + ) + .toBe(false); + + // Cleanuparr deletes with blocklist=true, so the replacement needs a release of its own. + const replacement = grabbableRelease(SONARR.searchMode, `${EPISODE}-E2EB${RUN_TAG}`, SONARR.category); + await indexerMock.resetAll(); + await indexerMock.stubMany(replacement.mappings); + + await triggerSeeker(api); + await waitForArrQueue(SONARR.arr, replacement.downloadId, TRIGGERED_SEARCH_TIMEOUT); + + await expect + .poll( + async () => { + const events = await listSearchEvents(api, instanceId); + return events.find((event) => event.searchReason === 'Replacement')?.searchStatus; + }, + { timeout: TRANSITION_TIMEOUT }, + ) + .toBe('Completed'); + + const events = await listSearchEvents(api, instanceId); + const replacementEvent = events.find((event) => event.searchReason === 'Replacement'); + + expect(replacementEvent?.grabbedItems).toContain(replacement.title); + }); +});