From 7d71fb9f94fa0b93c83bb5f2da67feb48cf16214 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 18 Jul 2026 16:42:13 +0200 Subject: [PATCH] fix(410): log scan cancellation below ERROR (user-initiated, not a failure) Scan cancellation surfaces as ScanCanceled, an ordinary BaseError, so all four scan handlers (Jellyfin/Emby/Plex/local) logged it at ERROR alongside genuine failures. A user cancelling a scan (or a container restart mid-scan) is not an error; this was training operators to ignore scanner ERROR lines, corrosive precisely because #264 showed scanner failures can be silent. Each handler's Left-result loop now branches on `error is ScanCanceled`: logs Information ("Scan of {Name} was canceled") for cancellation, keeps LogError for every other BaseError. No behavior change to LastScan stamping (still correctly skipped on any Left, cancellation included). fixes #410 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...chronizeJellyfinLibraryByIdHandlerTests.cs | 79 +++++++++++++++++++ .../ScanLocalLibraryHandlerTests.cs | 22 ++++++ .../SynchronizeEmbyLibraryByIdHandler.cs | 12 ++- .../SynchronizeJellyfinLibraryByIdHandler.cs | 14 +++- .../Commands/ScanLocalLibraryHandler.cs | 21 +++-- .../SynchronizePlexLibraryByIdHandler.cs | 10 ++- 6 files changed, 148 insertions(+), 10 deletions(-) diff --git a/ErsatzTV.Scanner.Tests/Application/Jellyfin/SynchronizeJellyfinLibraryByIdHandlerTests.cs b/ErsatzTV.Scanner.Tests/Application/Jellyfin/SynchronizeJellyfinLibraryByIdHandlerTests.cs index 79163189e..f07ad772f 100644 --- a/ErsatzTV.Scanner.Tests/Application/Jellyfin/SynchronizeJellyfinLibraryByIdHandlerTests.cs +++ b/ErsatzTV.Scanner.Tests/Application/Jellyfin/SynchronizeJellyfinLibraryByIdHandlerTests.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Jellyfin; @@ -95,5 +96,83 @@ public class SynchronizeJellyfinLibraryByIdHandlerTests Arg.Any()); await libraryRepository.Received(1).UpdateLastScan(library); } + + [Test] + public async Task Should_Not_Log_Error_When_Scan_Is_Canceled() + { + var scannerProxy = Substitute.For(); + var mediaSourceRepository = Substitute.For(); + var jellyfinSecretStore = Substitute.For(); + var jellyfinMovieLibraryScanner = Substitute.For(); + var jellyfinTelevisionLibraryScanner = Substitute.For(); + var jellyfinMusicVideoLibraryScanner = Substitute.For(); + var libraryRepository = Substitute.For(); + var configElementRepository = Substitute.For(); + var logger = Substitute.For>(); + + var library = new JellyfinLibrary + { + Id = 42, + Name = "Concerts", + MediaKind = LibraryMediaKind.MusicVideos, + MediaSourceId = 7 + }; + var mediaSource = new JellyfinMediaSource + { + Id = 7, + Connections = + [ + new JellyfinConnection + { + Address = "http://jellyfin.example", + JellyfinMediaSourceId = 7 + } + ] + }; + + mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask()); + mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask()); + jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets + { + Address = "http://jellyfin.example", + ApiKey = "abc" + }); + configElementRepository.GetValue( + Arg.Is(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key), + Arg.Any()) + .Returns(Task.FromResult>(Some(0))); + jellyfinMusicVideoLibraryScanner.ScanLibrary( + Arg.Any(), + library, + true, + Arg.Any()) + .Returns(Left(new ScanCanceled()).AsTask()); + + var handler = new SynchronizeJellyfinLibraryByIdHandler( + scannerProxy, + mediaSourceRepository, + jellyfinSecretStore, + jellyfinMovieLibraryScanner, + jellyfinTelevisionLibraryScanner, + jellyfinMusicVideoLibraryScanner, + libraryRepository, + configElementRepository, + logger); + + await handler.Handle( + new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true), + CancellationToken.None); + + // a user-initiated cancellation is not a failure and must not be logged at ERROR (#410) + await libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any()); + logger.ReceivedCalls() + .Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString() + == "Error synchronizing jellyfin library: Scan was canceled") + .ShouldBeFalse(); + logger.ReceivedCalls() + .Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString() + == "Scan of jellyfin library Concerts was canceled") + .ShouldBeTrue(); + } } } diff --git a/ErsatzTV.Scanner.Tests/Application/MediaSources/ScanLocalLibraryHandlerTests.cs b/ErsatzTV.Scanner.Tests/Application/MediaSources/ScanLocalLibraryHandlerTests.cs index f568166b1..69a98f9c6 100644 --- a/ErsatzTV.Scanner.Tests/Application/MediaSources/ScanLocalLibraryHandlerTests.cs +++ b/ErsatzTV.Scanner.Tests/Application/MediaSources/ScanLocalLibraryHandlerTests.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Scanner.Application.MediaSources; using ErsatzTV.Scanner.Core.Interfaces; @@ -96,6 +97,27 @@ public class ScanLocalLibraryHandlerTests ShouldHaveLogged("Error scanning local library path /movies: scan failed"); } + [Test] + public async Task Should_Not_Log_Error_When_A_Path_Scan_Is_Canceled() + { + ScanResult(Left(new ScanCanceled())); + + await Handler().Handle( + new ScanLocalLibrary("http://ersatztv.example", _library.Id, true), + CancellationToken.None); + + // a user-initiated cancellation is not a failure and must not be logged at ERROR (#410); + // it's still correctly excluded from the "last scan" stamp, same as any other failure + _library.LastScan.ShouldBeNull(); + await _libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any()); + + _logger.ReceivedCalls() + .Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString() + == "Error scanning local library path /movies: Scan was canceled") + .ShouldBeFalse(); + ShouldHaveLogged("Scan of local library path /movies was canceled"); + } + [Test] public async Task Should_Not_Set_Library_LastScan_When_A_Later_Path_Fails() { diff --git a/ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs b/ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs index 5f8b4239a..7c7cceae3 100644 --- a/ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs +++ b/ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs @@ -1,6 +1,7 @@ -using ErsatzTV.Core; +using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Scanner.Core.Interfaces; @@ -86,7 +87,14 @@ public class SynchronizeEmbyLibraryByIdHandler : IRequestHandler parameters.Library.Name); diff --git a/ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs b/ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs index 6e14a2b69..efe0beb77 100644 --- a/ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs +++ b/ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs @@ -1,5 +1,6 @@ -using ErsatzTV.Core; +using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Jellyfin; @@ -95,7 +96,16 @@ public class foreach (BaseError error in result.LeftToSeq()) { - _logger.LogError("Error synchronizing jellyfin library: {Error}", error); + if (error is ScanCanceled) + { + _logger.LogInformation( + "Scan of jellyfin library {Name} was canceled", + parameters.Library.Name); + } + else + { + _logger.LogError("Error synchronizing jellyfin library: {Error}", error); + } } return result.Map(_ => parameters.Library.Name); diff --git a/ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs b/ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs index 0ce27ca8c..23a992d91 100644 --- a/ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs +++ b/ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Scanner.Core.Interfaces; using ErsatzTV.Scanner.Core.Interfaces.Metadata; @@ -152,13 +153,23 @@ public class ScanLocalLibraryHandler : IRequestHandler parameters.Library.Name);