Compare commits

...
Author SHA1 Message Date
timothyandClaude Opus 4.8 7d71fb9f94 fix(410): log scan cancellation below ERROR (user-initiated, not a failure)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m56s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 39s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 19m2s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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) <noreply@anthropic.com>
2026-07-18 16:42:13 +02:00
6 changed files with 148 additions and 10 deletions
@@ -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<CancellationToken>());
await libraryRepository.Received(1).UpdateLastScan(library);
}
[Test]
public async Task Should_Not_Log_Error_When_Scan_Is_Canceled()
{
var scannerProxy = Substitute.For<IScannerProxy>();
var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
var libraryRepository = Substitute.For<ILibraryRepository>();
var configElementRepository = Substitute.For<IConfigElementRepository>();
var logger = Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>();
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<int>(
Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Option<int>>(Some(0)));
jellyfinMusicVideoLibraryScanner.ScanLibrary(
Arg.Any<JellyfinConnectionParameters>(),
library,
true,
Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(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<Library>());
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();
}
}
}
@@ -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<BaseError, Unit>(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<Library>());
_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()
{
@@ -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<SynchronizeEmby
foreach (BaseError error in result.LeftToSeq())
{
_logger.LogError("Error synchronizing emby library: {Error}", error);
if (error is ScanCanceled)
{
_logger.LogInformation("Scan of emby library {Name} was canceled", parameters.Library.Name);
}
else
{
_logger.LogError("Error synchronizing emby library: {Error}", error);
}
}
return result.Map(_ => parameters.Library.Name);
@@ -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);
@@ -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<ScanLocalLibrary, Either<
// a failed path now suppresses the library-level scan time below, so without this the
// user sees "Never scanned" with nothing explaining why. The remote scanners log the
// same way.
// same way. A cancellation is user-initiated, not a failure, so it's logged separately
// at a lower level; genuine errors still log at ERROR.
foreach (BaseError error in result.LeftToSeq())
{
_logger.LogError(
"Error scanning local library path {Path}: {Error}",
libraryPath.Path,
error);
if (error is ScanCanceled)
{
_logger.LogInformation(
"Scan of local library path {Path} was canceled",
libraryPath.Path);
}
else
{
_logger.LogError(
"Error scanning local library path {Path}: {Error}",
libraryPath.Path,
error);
}
}
}
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Plex;
@@ -96,7 +97,14 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
foreach (BaseError error in result.LeftToSeq())
{
_logger.LogError("Error synchronizing plex library: {Error}", error);
if (error is ScanCanceled)
{
_logger.LogInformation("Scan of plex library {Name} was canceled", parameters.Library.Name);
}
else
{
_logger.LogError("Error synchronizing plex library: {Error}", error);
}
}
return result.Map(_ => parameters.Library.Name);