fixed stale monitored flag on touched cf entries

This commit is contained in:
Flaminel committed 2026-10-27 10:38:58 +02:00
1 parent 19576bc338
commit e3f3607bcf
2 files changed
+186 -19

No files matched your search

@@ -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<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableMovie>([
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<CustomFormatScoreEntry> 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<CancellationToken>())
.Returns(ToAsyncEnumerable<SearchableSeries>([
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<CustomFormatScoreEntry> entries = await _fixture.EventsContext.CustomFormatScoreEntries.AsNoTracking().ToListAsync();
entries.ShouldHaveSingleItem();
entries[0].IsMonitored.ShouldBeFalse();
}
[Fact]
public async Task ExecuteAsync_Sonarr_PreservesEntryWhenSeriesFetchFails()
{
@@ -153,7 +153,7 @@ public sealed class CustomFormatScoreSyncer : IHandler
DateTimeOffset now = _timeProvider.GetUtcNow();
List<(SearchableMovie Movie, long FileId)> withFilesBuffer = new(ChunkSize);
List<long> 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<long> movieIds, DateTimeOffset now)
private async Task FlushMovieTouchChunkAsync(ArrInstance arrInstance, List<(long Id, bool Monitored)> movies, DateTimeOffset now)
{
InstanceType instanceType = arrInstance.ArrConfig.Type;
List<long> 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<bool, (long Id, bool Monitored)> group in movies.GroupBy(x => x.Monitored))
{
bool isMonitored = group.Key;
List<long> 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<long> 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<long, (long ArtistId, long AlbumId)> 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<long> 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);
}
}