From 5c8dd64acf3286b77a6bc67ea91761a5d5d553f3 Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 17 Jul 2026 14:18:14 +0200 Subject: [PATCH] fix(264): record library-level LastScan after a successful local scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local libraries permanently showed "Never scanned" in the SPA libraries hub regardless of successful scans, even as item counts updated. Root cause: ScanLocalLibraryHandler wrote only the path-level LibraryPath.LastScan (which gates the per-path refresh interval) and never the library-level Library.LastScan. The read API (GetAllMediaSourcesForApiHandler) populates the hub's scan-time badge from Library.LastScan, so that value stayed null forever. The three remote scanners (Jellyfin/Emby/Plex) already set the library-level value; only the local scanner did not. Both sides predate #202 — the SPA hub merely made the missing value visible. Mirror the remote scanners' semantics: record the library-level scan time only when the scan actually ran and every path that ran succeeded, so a skipped (unforced, interval not elapsed) or partially-failed scan does not claim a successful scan time. Also de-BOM the touched handler per the fix-as-you-touch charset gate (#311). fixes #264 --- .../ScanLocalLibraryHandlerTests.cs | 173 ++++++++++++++++++ .../Commands/ScanLocalLibraryHandler.cs | 16 +- 2 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 ErsatzTV.Scanner.Tests/Application/MediaSources/ScanLocalLibraryHandlerTests.cs diff --git a/ErsatzTV.Scanner.Tests/Application/MediaSources/ScanLocalLibraryHandlerTests.cs b/ErsatzTV.Scanner.Tests/Application/MediaSources/ScanLocalLibraryHandlerTests.cs new file mode 100644 index 000000000..f702f75bd --- /dev/null +++ b/ErsatzTV.Scanner.Tests/Application/MediaSources/ScanLocalLibraryHandlerTests.cs @@ -0,0 +1,173 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Scanner.Application.MediaSources; +using ErsatzTV.Scanner.Core.Interfaces; +using ErsatzTV.Scanner.Core.Interfaces.Metadata; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Scanner.Tests.Application.MediaSources; + +public class ScanLocalLibraryHandlerTests +{ + [TestFixture] + public class Handle + { + [SetUp] + public void SetUp() + { + // the handler validates that the configured ffmpeg/ffprobe paths exist on disk + _ffmpegPath = Path.GetTempFileName(); + _ffprobePath = Path.GetTempFileName(); + + _scannerProxy = Substitute.For(); + _libraryRepository = Substitute.For(); + _configElementRepository = Substitute.For(); + _movieFolderScanner = Substitute.For(); + + _library = new LocalLibrary + { + Id = 42, + Name = "Movies", + MediaKind = LibraryMediaKind.Movies, + MediaSourceId = 1, + Paths = [new LibraryPath { Id = 1, Path = "/movies" }] + }; + + _libraryRepository.GetLibrary(_library.Id).Returns(Some(_library).AsTask()); + + ConfigValue(ConfigElementKey.FFmpegPath, _ffmpegPath); + ConfigValue(ConfigElementKey.FFprobePath, _ffprobePath); + ConfigValue(ConfigElementKey.LibraryRefreshInterval, 0); + } + + [TearDown] + public void TearDown() + { + File.Delete(_ffmpegPath); + File.Delete(_ffprobePath); + } + + private string _ffmpegPath; + private string _ffprobePath; + private IScannerProxy _scannerProxy; + private ILibraryRepository _libraryRepository; + private IConfigElementRepository _configElementRepository; + private IMovieFolderScanner _movieFolderScanner; + private LocalLibrary _library; + + [Test] + public async Task Should_Set_Library_LastScan_After_Successful_Scan() + { + ScanResult(Right(Unit.Default)); + + Either result = await Handler().Handle( + new ScanLocalLibrary("http://ersatztv.example", _library.Id, true), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + + // the regression: only the path-level LastScan was written, so the API's + // library-level scan time stayed null and the SPA showed "Never scanned" forever + _library.LastScan.ShouldNotBeNull(); + await _libraryRepository.Received(1).UpdateLastScan(_library); + await _libraryRepository.Received(1).UpdateLastScan(_library.Paths[0]); + } + + [Test] + public async Task Should_Not_Set_Library_LastScan_When_A_Path_Scan_Fails() + { + ScanResult(Left(new BaseError("scan failed"))); + + await Handler().Handle( + new ScanLocalLibrary("http://ersatztv.example", _library.Id, true), + CancellationToken.None); + + _library.LastScan.ShouldBeNull(); + await _libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any()); + } + + [Test] + public async Task Should_Not_Set_Library_LastScan_When_A_Later_Path_Fails() + { + var goodPath = new LibraryPath { Id = 1, Path = "/movies" }; + var badPath = new LibraryPath { Id = 2, Path = "/more-movies" }; + _library.Paths = [goodPath, badPath]; + + _movieFolderScanner.ScanFolder( + goodPath, + _ffmpegPath, + _ffprobePath, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Right(Unit.Default).AsTask()); + _movieFolderScanner.ScanFolder( + badPath, + _ffmpegPath, + _ffprobePath, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Left(new BaseError("scan failed")).AsTask()); + + await Handler().Handle( + new ScanLocalLibrary("http://ersatztv.example", _library.Id, true), + CancellationToken.None); + + // a partially-scanned library must not claim a successful scan time + _library.LastScan.ShouldBeNull(); + await _libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any()); + await _libraryRepository.Received(1).UpdateLastScan(goodPath); + } + + [Test] + public async Task Should_Not_Set_Library_LastScan_When_Scan_Is_Skipped() + { + ScanResult(Right(Unit.Default)); + _library.Paths[0].LastScan = DateTime.UtcNow; + ConfigValue(ConfigElementKey.LibraryRefreshInterval, 6); + + await Handler().Handle( + new ScanLocalLibrary("http://ersatztv.example", _library.Id, false), + CancellationToken.None); + + // nothing was scanned, so there is no new scan time to record + _library.LastScan.ShouldBeNull(); + await _libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any()); + } + + private void ConfigValue(ConfigElementKey key, T value) => + _configElementRepository.GetValue( + Arg.Is(k => k.Key == key.Key), + Arg.Any()) + .Returns(Task.FromResult>(Some(value))); + + private void ScanResult(Either result) => + _movieFolderScanner.ScanFolder( + Arg.Any(), + _ffmpegPath, + _ffprobePath, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(result.AsTask()); + + private ScanLocalLibraryHandler Handler() => + new( + _scannerProxy, + _libraryRepository, + _configElementRepository, + _movieFolderScanner, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For>()); + } +} diff --git a/ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs b/ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs index e2ec05a77..f24a68818 100644 --- a/ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs +++ b/ErsatzTV.Scanner/Application/MediaSources/Commands/ScanLocalLibraryHandler.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; @@ -65,6 +65,7 @@ public class ScanLocalLibraryHandler : IRequestHandler