Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Has started running
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com>
209 lines
8.5 KiB
C#
209 lines
8.5 KiB
C#
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;
|
|
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<IScannerProxy>();
|
|
_libraryRepository = Substitute.For<ILibraryRepository>();
|
|
_configElementRepository = Substitute.For<IConfigElementRepository>();
|
|
_movieFolderScanner = Substitute.For<IMovieFolderScanner>();
|
|
_logger = Substitute.For<ILogger<ScanLocalLibraryHandler>>();
|
|
|
|
_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>(_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 ILogger<ScanLocalLibraryHandler> _logger;
|
|
private LocalLibrary _library;
|
|
|
|
[Test]
|
|
public async Task Should_Set_Library_LastScan_After_Successful_Scan()
|
|
{
|
|
ScanResult(Right<BaseError, Unit>(Unit.Default));
|
|
|
|
Either<BaseError, string> 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<BaseError, Unit>(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<Library>());
|
|
|
|
// a failed path suppresses the library-level scan time, reproducing the #264 symptom;
|
|
// without this log the user would have nothing explaining why
|
|
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()
|
|
{
|
|
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<decimal>(),
|
|
Arg.Any<decimal>(),
|
|
Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
|
|
_movieFolderScanner.ScanFolder(
|
|
badPath,
|
|
_ffmpegPath,
|
|
_ffprobePath,
|
|
Arg.Any<decimal>(),
|
|
Arg.Any<decimal>(),
|
|
Arg.Any<CancellationToken>())
|
|
.Returns(Left<BaseError, Unit>(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<Library>());
|
|
await _libraryRepository.Received(1).UpdateLastScan(goodPath);
|
|
}
|
|
|
|
[Test]
|
|
public async Task Should_Not_Set_Library_LastScan_When_Scan_Is_Skipped()
|
|
{
|
|
ScanResult(Right<BaseError, Unit>(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<Library>());
|
|
}
|
|
|
|
private void ConfigValue<T>(ConfigElementKey key, T value) =>
|
|
_configElementRepository.GetValue<T>(
|
|
Arg.Is<ConfigElementKey>(k => k.Key == key.Key),
|
|
Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<Option<T>>(Some(value)));
|
|
|
|
private void ScanResult(Either<BaseError, Unit> result) =>
|
|
_movieFolderScanner.ScanFolder(
|
|
Arg.Any<LibraryPath>(),
|
|
_ffmpegPath,
|
|
_ffprobePath,
|
|
Arg.Any<decimal>(),
|
|
Arg.Any<decimal>(),
|
|
Arg.Any<CancellationToken>())
|
|
.Returns(result.AsTask());
|
|
|
|
private ScanLocalLibraryHandler Handler() =>
|
|
new(
|
|
_scannerProxy,
|
|
_libraryRepository,
|
|
_configElementRepository,
|
|
_movieFolderScanner,
|
|
Substitute.For<ITelevisionFolderScanner>(),
|
|
Substitute.For<IMusicVideoFolderScanner>(),
|
|
Substitute.For<IOtherVideoFolderScanner>(),
|
|
Substitute.For<ISongFolderScanner>(),
|
|
Substitute.For<IImageFolderScanner>(),
|
|
Substitute.For<IRemoteStreamFolderScanner>(),
|
|
_logger);
|
|
|
|
// asserts on the substituted ILogger.Log call the LogError extension dispatches to
|
|
// (same idiom as ErsatzTV.Tests' ReleaseMemoryHandlerTests.ShouldHaveLogged)
|
|
private void ShouldHaveLogged(string expectedMessage) =>
|
|
_logger.ReceivedCalls()
|
|
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString() == expectedMessage)
|
|
.ShouldBeTrue();
|
|
}
|
|
}
|