From facf8280d3ab02e2280c41af3eedb139289fb4b4 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Tue, 27 Oct 2026 10:39:30 +0200 Subject: [PATCH] fixed cf history cleanup deleting sibling items --- .../Jobs/CustomFormatScoreSyncerTests.cs | 151 ++++++++++++++++++ .../Features/Jobs/CustomFormatScoreSyncer.cs | 38 ++--- 2 files changed, 166 insertions(+), 23 deletions(-) diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/CustomFormatScoreSyncerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/CustomFormatScoreSyncerTests.cs index a5bd4d55..bb756918 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/CustomFormatScoreSyncerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/CustomFormatScoreSyncerTests.cs @@ -1221,6 +1221,55 @@ public class CustomFormatScoreSyncerTests : IDisposable (await _fixture.EventsContext.CustomFormatScoreEntries.AsNoTracking().ToListAsync()).ShouldHaveSingleItem().IsMonitored.ShouldBeFalse(); } + [Fact] + public async Task ExecuteAsync_LidarrSync_RemovedAlbumKeepsSiblingHistory() + { + // Arrange + ArrInstance lidarrInstance = await AddLidarrWithSyncAsync(); + foreach (long albumId in new long[] { 50, 999 }) + { + _fixture.EventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = lidarrInstance.Id, + ExternalItemId = 5, + EpisodeId = albumId, + ItemType = InstanceType.Lidarr, + Title = $"Test Artist - Album {albumId}", + FileId = 900 + albumId, + CurrentScore = 120, + CutoffScore = 300, + QualityProfileName = "Lossless", + LastSyncedAt = new DateTime(1999, 1, 1, 0, 0, 0, DateTimeKind.Utc) + }); + _fixture.EventsContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory + { + ArrInstanceId = lidarrInstance.Id, + ExternalItemId = 5, + EpisodeId = albumId, + ItemType = InstanceType.Lidarr, + Title = $"Test Artist - Album {albumId}", + Score = 120, + CutoffScore = 300, + RecordedAt = DateTime.UtcNow.AddHours(-1) + }); + } + await _fixture.EventsContext.SaveChangesAsync(); + StubLidarrLibrary( + lidarrInstance, + LidarrArtist(), + [LidarrAlbum(50, trackFileCount: 1, trackCount: 1)], + [new ArrTrackFile { Id = 950, AlbumId = 50, CustomFormatScore = 120 }]); + + CustomFormatScoreSyncer sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + (await _fixture.EventsContext.CustomFormatScoreEntries.ToListAsync()).ShouldHaveSingleItem().EpisodeId.ShouldBe(50); + (await _fixture.EventsContext.CustomFormatScoreHistory.ToListAsync()).ShouldHaveSingleItem().EpisodeId.ShouldBe(50); + } + #endregion #region Score Unchanged Tests @@ -1707,6 +1756,108 @@ public class CustomFormatScoreSyncerTests : IDisposable entries[0].IsMonitored.ShouldBeFalse(); } + [Fact] + public async Task ExecuteAsync_Sonarr_RemovedEpisodeKeepsSiblingHistory() + { + // Arrange + ArrInstance sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = sonarrInstance.Id, + ArrInstance = sonarrInstance, + Enabled = true, + UseCustomFormatScore = true + }); + + _fixture.EventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = sonarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 100, + ItemType = InstanceType.Sonarr, + Title = "Test Series S01E01", + FileId = 500, + CurrentScore = 300, + CutoffScore = 500, + QualityProfileName = "HD", + LastSyncedAt = new DateTime(1999, 1, 1, 0, 0, 0, DateTimeKind.Utc) + }); + _fixture.EventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = sonarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 101, + ItemType = InstanceType.Sonarr, + Title = "Test Series S01E02", + FileId = 501, + CurrentScore = 300, + CutoffScore = 500, + QualityProfileName = "HD", + LastSyncedAt = new DateTime(1999, 1, 1, 0, 0, 0, DateTimeKind.Utc) + }); + _fixture.EventsContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory + { + ArrInstanceId = sonarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 100, + ItemType = InstanceType.Sonarr, + Title = "Test Series S01E01", + Score = 300, + CutoffScore = 500, + RecordedAt = DateTime.UtcNow.AddHours(-1) + }); + _fixture.EventsContext.CustomFormatScoreHistory.Add(new CustomFormatScoreHistory + { + ArrInstanceId = sonarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 101, + ItemType = InstanceType.Sonarr, + Title = "Test Series S01E02", + Score = 300, + CutoffScore = 500, + RecordedAt = DateTime.UtcNow.AddHours(-1) + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + _sonarrClient + .GetQualityProfilesAsync(sonarrInstance) + .Returns([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]); + + _sonarrClient + .StreamAllSeriesAsync(sonarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableSeries { Id = 10, Title = "Test Series", QualityProfileId = 1, Monitored = true } + ])); + + _sonarrClient + .GetEpisodesAsync(sonarrInstance, 10) + .Returns([ + new SearchableEpisode { Id = 100, SeasonNumber = 1, EpisodeNumber = 1, EpisodeFileId = 500, HasFile = true, Monitored = true } + ]); + + _sonarrClient + .GetEpisodeFilesAsync(sonarrInstance, 10) + .Returns([ + new ArrEpisodeFile { Id = 500, CustomFormatScore = 300, QualityCutoffNotMet = false } + ]); + + CustomFormatScoreSyncer sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + List entries = await _fixture.EventsContext.CustomFormatScoreEntries.ToListAsync(); + entries.ShouldHaveSingleItem(); + entries[0].EpisodeId.ShouldBe(100); + + List history = await _fixture.EventsContext.CustomFormatScoreHistory.ToListAsync(); + history.ShouldHaveSingleItem(); + history[0].EpisodeId.ShouldBe(100); + } + [Fact] public async Task ExecuteAsync_Sonarr_PreservesEntryWhenSeriesFetchFails() { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/CustomFormatScoreSyncer.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/CustomFormatScoreSyncer.cs index 22a8e7ec..c85001d2 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/CustomFormatScoreSyncer.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/CustomFormatScoreSyncer.cs @@ -839,37 +839,29 @@ public sealed class CustomFormatScoreSyncer : IHandler private async Task CleanupStaleEntriesAsync( Guid arrInstanceId, InstanceType instanceType, DateTimeOffset syncStartTime) { - List staleItemIds = await _eventsContext.CustomFormatScoreEntries - .Where(e => e.ArrInstanceId == arrInstanceId - && e.ItemType == instanceType - && e.LastSyncedAt < syncStartTime) - .Select(e => e.ExternalItemId) - .Distinct() - .ToListAsync(); - - if (staleItemIds.Count == 0) - { - return; - } - - await _eventsContext.CustomFormatScoreEntries + int deletedEntries = await _eventsContext.CustomFormatScoreEntries .Where(e => e.ArrInstanceId == arrInstanceId && e.ItemType == instanceType && e.LastSyncedAt < syncStartTime) .ExecuteDeleteAsync(); - foreach (long[] chunk in staleItemIds.Chunk(ChunkSize)) + if (deletedEntries == 0) { - List chunkList = chunk.ToList(); - await _eventsContext.CustomFormatScoreHistory - .Where(h => h.ArrInstanceId == arrInstanceId - && h.ItemType == instanceType - && chunkList.Contains(h.ExternalItemId)) - .ExecuteDeleteAsync(); + return; } - _logger.LogTrace("Cleaned up {Count} stale CF score item(s) for instance {InstanceId}", - staleItemIds.Count, arrInstanceId); + // Match on EpisodeId too, so a removed album or episode keeps its siblings' history + await _eventsContext.CustomFormatScoreHistory + .Where(h => h.ArrInstanceId == arrInstanceId + && h.ItemType == instanceType + && !_eventsContext.CustomFormatScoreEntries.Any(e => e.ArrInstanceId == h.ArrInstanceId + && e.ItemType == h.ItemType + && e.ExternalItemId == h.ExternalItemId + && e.EpisodeId == h.EpisodeId)) + .ExecuteDeleteAsync(); + + _logger.LogTrace("Cleaned up {Count} stale CF score entries for instance {InstanceId}", + deletedEntries, arrInstanceId); } ///