From e3f3607bcfb56317eef172175140ffdcd1d0e626 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Tue, 27 Oct 2026 10:38:58 +0200 Subject: [PATCH] fixed stale monitored flag on touched cf entries --- .../Jobs/CustomFormatScoreSyncerTests.cs | 154 ++++++++++++++++++ .../Features/Jobs/CustomFormatScoreSyncer.cs | 51 +++--- 2 files changed, 186 insertions(+), 19 deletions(-) diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/CustomFormatScoreSyncerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/CustomFormatScoreSyncerTests.cs index 7ab6e5a4..a5bd4d55 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/CustomFormatScoreSyncerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/CustomFormatScoreSyncerTests.cs @@ -1186,6 +1186,41 @@ public class CustomFormatScoreSyncerTests : IDisposable (await _fixture.EventsContext.CustomFormatScoreEntries.ToListAsync()).ShouldHaveSingleItem().EpisodeId.ShouldBe(50); } + [Fact] + public async Task ExecuteAsync_LidarrAlbumLosingTracksAndUnmonitored_ClearsIsMonitored() + { + // Arrange + ArrInstance lidarrInstance = await AddLidarrWithSyncAsync(); + _fixture.EventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = lidarrInstance.Id, + ExternalItemId = 5, + EpisodeId = 50, + ItemType = InstanceType.Lidarr, + Title = "Test Artist - Album 50", + FileId = 901, + CurrentScore = 120, + CutoffScore = 300, + QualityProfileName = "Lossless", + IsMonitored = true, + LastSyncedAt = new DateTime(1999, 1, 1, 0, 0, 0, DateTimeKind.Utc) + }); + await _fixture.EventsContext.SaveChangesAsync(); + StubLidarrLibrary( + lidarrInstance, + LidarrArtist(), + [LidarrAlbum(50, trackFileCount: 2, trackCount: 3, monitored: false)], + [new ArrTrackFile { Id = 901, AlbumId = 50, CustomFormatScore = 120 }]); + + CustomFormatScoreSyncer sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + (await _fixture.EventsContext.CustomFormatScoreEntries.AsNoTracking().ToListAsync()).ShouldHaveSingleItem().IsMonitored.ShouldBeFalse(); + } + #endregion #region Score Unchanged Tests @@ -1396,6 +1431,63 @@ public class CustomFormatScoreSyncerTests : IDisposable history[0].Score.ShouldBe(250); } + [Fact] + public async Task ExecuteAsync_ClearsIsMonitoredWhenMovieWithoutFileIsUnmonitored() + { + // Arrange + ArrInstance radarrInstance = TestDataContextFactory.AddRadarrInstance(_fixture.DataContext); + + _fixture.DataContext.SeekerInstanceConfigs.Add(new SeekerInstanceConfig + { + ArrInstanceId = radarrInstance.Id, + ArrInstance = radarrInstance, + Enabled = true, + UseCustomFormatScore = true + }); + + _fixture.EventsContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry + { + ArrInstanceId = radarrInstance.Id, + ExternalItemId = 10, + EpisodeId = 0, + ItemType = InstanceType.Radarr, + Title = "Mario Bros", + FileId = 100, + CurrentScore = 250, + CutoffScore = 500, + QualityProfileName = "HD", + IsMonitored = true, + LastSyncedAt = DateTime.UtcNow.AddHours(-1) + }); + await _fixture.DataContext.SaveChangesAsync(); + await _fixture.EventsContext.SaveChangesAsync(); + + _radarrClient + .GetQualityProfilesAsync(radarrInstance) + .Returns([new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 500 }]); + + _radarrClient + .StreamAllMoviesAsync(radarrInstance, Arg.Any()) + .Returns(ToAsyncEnumerable([ + new SearchableMovie + { + Id = 10, Title = "Mario Bros", HasFile = false, + MovieFile = null, + QualityProfileId = 1, Status = "released", Monitored = false + } + ])); + + CustomFormatScoreSyncer sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + List entries = await _fixture.EventsContext.CustomFormatScoreEntries.AsNoTracking().ToListAsync(); + entries.ShouldHaveSingleItem(); + entries[0].IsMonitored.ShouldBeFalse(); + } + [Fact] public async Task ExecuteAsync_PreservesEntryWhenMovieFileScoreNotReturned() { @@ -1553,6 +1645,68 @@ public class CustomFormatScoreSyncerTests : IDisposable history.ShouldHaveSingleItem(); } + [Fact] + public async Task ExecuteAsync_Sonarr_ClearsIsMonitoredWhenEpisodeWithoutFileIsUnmonitored() + { + // 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", + IsMonitored = true, + LastSyncedAt = new DateTime(1999, 1, 1, 0, 0, 0, DateTimeKind.Utc) + }); + 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 = 0, HasFile = false, Monitored = false } + ]); + + _sonarrClient + .GetEpisodeFilesAsync(sonarrInstance, 10) + .Returns([]); + + CustomFormatScoreSyncer sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + List entries = await _fixture.EventsContext.CustomFormatScoreEntries.AsNoTracking().ToListAsync(); + entries.ShouldHaveSingleItem(); + entries[0].IsMonitored.ShouldBeFalse(); + } + [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 b1370f24..22a8e7ec 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/CustomFormatScoreSyncer.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/CustomFormatScoreSyncer.cs @@ -153,7 +153,7 @@ public sealed class CustomFormatScoreSyncer : IHandler DateTimeOffset now = _timeProvider.GetUtcNow(); List<(SearchableMovie Movie, long FileId)> withFilesBuffer = new(ChunkSize); - List withoutFilesBuffer = new(ChunkSize); + List<(long Id, bool Monitored)> withoutFilesBuffer = new(ChunkSize); int totalMovies = 0; int totalWithFiles = 0; @@ -182,7 +182,7 @@ public sealed class CustomFormatScoreSyncer : IHandler } else { - withoutFilesBuffer.Add(movie.Id); + withoutFilesBuffer.Add((movie.Id, movie.Monitored)); if (withoutFilesBuffer.Count >= ChunkSize) { @@ -221,15 +221,21 @@ public sealed class CustomFormatScoreSyncer : IHandler instanceType, totalSynced, arrInstance.Name, totalSkipped); } - private async Task FlushMovieTouchChunkAsync(ArrInstance arrInstance, List movieIds, DateTimeOffset now) + private async Task FlushMovieTouchChunkAsync(ArrInstance arrInstance, List<(long Id, bool Monitored)> movies, DateTimeOffset now) { InstanceType instanceType = arrInstance.ArrConfig.Type; - List chunkList = movieIds.ToList(); - await _eventsContext.CustomFormatScoreEntries - .Where(e => e.ArrInstanceId == arrInstance.Id - && e.ItemType == instanceType - && chunkList.Contains(e.ExternalItemId)) - .ExecuteUpdateAsync(s => s.SetProperty(e => e.LastSyncedAt, now)); + foreach (IGrouping group in movies.GroupBy(x => x.Monitored)) + { + bool isMonitored = group.Key; + List chunkList = group.Select(x => x.Id).ToList(); + await _eventsContext.CustomFormatScoreEntries + .Where(e => e.ArrInstanceId == arrInstance.Id + && e.ItemType == instanceType + && chunkList.Contains(e.ExternalItemId)) + .ExecuteUpdateAsync(s => s + .SetProperty(e => e.LastSyncedAt, now) + .SetProperty(e => e.IsMonitored, isMonitored)); + } } private async Task<(int Synced, int Skipped)> FlushMovieWithFilesChunkAsync( @@ -269,6 +275,7 @@ public sealed class CustomFormatScoreSyncer : IHandler if (existingEntries.TryGetValue(movie.Id, out CustomFormatScoreEntry? skippedEntry)) { skippedEntry.LastSyncedAt = now; + skippedEntry.IsMonitored = movie.Monitored; } _logger.LogTrace("[{InstanceType}] {InstanceName}: skipping movie '{Title}' (fileId={FileId}), no score returned", instanceType, arrInstance.Name, movie.Title, fileId); @@ -379,7 +386,7 @@ public sealed class CustomFormatScoreSyncer : IHandler // Collect all episodes with files for this chunk of series List<(SearchableSeries Series, SearchableEpisode Episode, long FileId, int CfScore, bool IsMonitored)> itemsInChunk = []; - List<(long SeriesId, long EpisodeId)> episodesToTouch = []; + List<(long SeriesId, long EpisodeId, bool IsMonitored)> episodesToTouch = []; foreach (SeriesFetch seriesFetch in fetched) { @@ -399,7 +406,7 @@ public sealed class CustomFormatScoreSyncer : IHandler } else { - episodesToTouch.Add((series.Id, episode.Id)); + episodesToTouch.Add((series.Id, episode.Id, series.Monitored && episode.Monitored)); if (episode.EpisodeFileId > 0) { skipped++; @@ -454,8 +461,9 @@ public sealed class CustomFormatScoreSyncer : IHandler } // Touch entries for episodes that exist but have no file (e.g., RSS upgrade in progress) - foreach (var group in episodesToTouch.GroupBy(x => x.SeriesId)) + foreach (IGrouping<(long SeriesId, bool IsMonitored), (long SeriesId, long EpisodeId, bool IsMonitored)> group in episodesToTouch.GroupBy(x => (x.SeriesId, x.IsMonitored))) { + (long seriesId, bool isMonitored) = group.Key; List episodeIds = group.Select(x => x.EpisodeId).ToList(); foreach (long[] epChunk in episodeIds.Chunk(ChunkSize)) { @@ -463,9 +471,11 @@ public sealed class CustomFormatScoreSyncer : IHandler await _eventsContext.CustomFormatScoreEntries .Where(e => e.ArrInstanceId == arrInstance.Id && e.ItemType == InstanceType.Sonarr - && e.ExternalItemId == group.Key + && e.ExternalItemId == seriesId && epChunkList.Contains(e.EpisodeId)) - .ExecuteUpdateAsync(s => s.SetProperty(e => e.LastSyncedAt, now), cancellationToken); + .ExecuteUpdateAsync(s => s + .SetProperty(e => e.LastSyncedAt, now) + .SetProperty(e => e.IsMonitored, isMonitored), cancellationToken); } } @@ -594,7 +604,7 @@ public sealed class CustomFormatScoreSyncer : IHandler .ToList(); List<(SearchableArtist Artist, SearchableAlbum Album, ArrTrackFile File)> itemsInChunk = []; - List<(long ArtistId, long AlbumId)> albumsToTouch = []; + List<(long ArtistId, long AlbumId, bool IsMonitored)> albumsToTouch = []; foreach (ArtistFetch artistFetch in fetched) { @@ -617,7 +627,7 @@ public sealed class CustomFormatScoreSyncer : IHandler } else { - albumsToTouch.Add((artist.Id, album.Id)); + albumsToTouch.Add((artist.Id, album.Id, artist.Monitored && album.Monitored)); if (hasAllTracks) { skipped++; @@ -672,8 +682,9 @@ public sealed class CustomFormatScoreSyncer : IHandler } // Keeps partial albums out of stale cleanup - foreach (IGrouping group in albumsToTouch.GroupBy(x => x.ArtistId)) + foreach (IGrouping<(long ArtistId, bool IsMonitored), (long ArtistId, long AlbumId, bool IsMonitored)> group in albumsToTouch.GroupBy(x => (x.ArtistId, x.IsMonitored))) { + (long artistId, bool isMonitored) = group.Key; List albumIds = group.Select(x => x.AlbumId).ToList(); foreach (long[] albumChunk in albumIds.Chunk(ChunkSize)) { @@ -681,9 +692,11 @@ public sealed class CustomFormatScoreSyncer : IHandler await _eventsContext.CustomFormatScoreEntries .Where(e => e.ArrInstanceId == arrInstance.Id && e.ItemType == InstanceType.Lidarr - && e.ExternalItemId == group.Key + && e.ExternalItemId == artistId && albumChunkList.Contains(e.EpisodeId)) - .ExecuteUpdateAsync(s => s.SetProperty(e => e.LastSyncedAt, now), cancellationToken); + .ExecuteUpdateAsync(s => s + .SetProperty(e => e.LastSyncedAt, now) + .SetProperty(e => e.IsMonitored, isMonitored), cancellationToken); } }