using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using PLib.Application.Abstractions; using PLib.Application.Library; using PLib.Domain.Videos; using Shouldly; namespace PLib.Tests.Library; public sealed class LibraryServiceTests { private const string Root = @"C:\videos"; private readonly InMemoryVideoRepository _repository = new(); private readonly IVideoFileScanner _scanner = Substitute.For(); private readonly IMediaProbe _probe = Substitute.For(); private readonly IThumbnailGenerator _thumbnails = Substitute.For(); public LibraryServiceTests() { _probe.ProbeAsync(Arg.Any(), Arg.Any()) .Returns(new VideoTechnicalInfo(TimeSpan.FromMinutes(2), 1920, 1080, "h264")); _thumbnails.GetOrCreateAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(callInfo => $@"C:\cache\{Path.GetFileNameWithoutExtension(callInfo.Arg())}.jpg"); // By default every remembered poster frame is still on disk. _thumbnails.IsAvailable(Arg.Any()).Returns(true); } [Fact] public async Task Files_that_are_new_on_disk_are_added_probed_and_given_a_thumbnail() { GivenFilesOnDisk(File(@"C:\videos\a.mp4"), File(@"C:\videos\b.mkv")); var events = await CollectAsync(CreateService()); _repository.Items.Count.ShouldBe(2); _repository.Items.ShouldAllBe(x => x.IsIndexed); events.OfType().Count().ShouldBe(2); events.OfType().Count().ShouldBe(2); events.OfType().Single().LibrarySize.ShouldBe(2); } [Fact] public async Task Entries_whose_file_is_gone_are_dropped_from_the_library() { _repository.Seed(new VideoItem(@"C:\videos\stale.mp4", "stale", 5_000, DateTimeOffset.UnixEpoch)); GivenFilesOnDisk(File(@"C:\videos\a.mp4")); var events = await CollectAsync(CreateService()); _repository.Items.Select(x => x.FullPath).ShouldBe([@"C:\videos\a.mp4"]); events.OfType().Count().ShouldBe(1); } [Fact] public async Task Files_below_the_minimum_size_are_not_part_of_the_library() { GivenFilesOnDisk(File(@"C:\videos\tiny.mp4", sizeInBytes: 128), File(@"C:\videos\real.mp4")); await CollectAsync(CreateService(new LibraryOptions { MinimumFileSizeInBytes = 1_024 })); _repository.Items.Select(x => x.FullPath).ShouldBe([@"C:\videos\real.mp4"]); } [Fact] public async Task An_item_that_is_already_indexed_is_not_probed_again() { var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch); indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264")); indexed.AttachThumbnail(@"C:\cache\a.jpg"); _repository.Seed(indexed); GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000)); await CollectAsync(CreateService()); await _probe.DidNotReceive().ProbeAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task An_item_whose_file_changed_on_disk_is_indexed_again() { var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch); indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264")); indexed.AttachThumbnail(@"C:\cache\old.jpg"); _repository.Seed(indexed); GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 9_999)); await CollectAsync(CreateService()); await _probe.Received(1).ProbeAsync(@"C:\videos\a.mp4", Arg.Any()); _repository.Items.Single().ThumbnailPath.ShouldBe(@"C:\cache\a.jpg"); } [Fact] public async Task A_poster_frame_that_disappeared_from_the_cache_is_generated_again() { var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch); indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264")); indexed.AttachThumbnail(@"C:\cache\deleted.jpg"); _repository.Seed(indexed); _thumbnails.IsAvailable(@"C:\cache\deleted.jpg").Returns(false); GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000)); await CollectAsync(CreateService()); await _thumbnails.Received(1) .GetOrCreateAsync(@"C:\videos\a.mp4", Arg.Any(), Arg.Any()); _repository.Items.Single().ThumbnailPath.ShouldBe(@"C:\cache\a.jpg"); } [Fact] public async Task Cached_frames_nothing_points_at_are_purged_once_the_scan_is_whole() { GivenFilesOnDisk(File(@"C:\videos\a.mp4")); await CollectAsync(CreateService()); await _thumbnails.Received(1).PurgeUnusedAsync( Arg.Is>(paths => paths != null && paths.SequenceEqual(new[] { @"C:\cache\a.jpg" })), Arg.Any()); } [Fact] public async Task The_same_file_reached_through_two_overlapping_roots_is_only_added_once() { GivenFilesOnDisk(File(@"C:\videos\a.mp4")); var events = await CollectAsync(CreateService(), Root, Root); _repository.Items.Count.ShouldBe(1); events.OfType().Single().FilesFound.ShouldBe(1); } private static DiscoveredVideoFile File(string path, long sizeInBytes = 10_000) => new(path, sizeInBytes, DateTimeOffset.UnixEpoch); private void GivenFilesOnDisk(params DiscoveredVideoFile[] files) => _scanner.ScanAsync(Arg.Any(), Arg.Any()) .Returns(_ => files.ToAsyncEnumerable()); private LibraryService CreateService(LibraryOptions? options = null) => new( _repository, _scanner, _probe, _thumbnails, Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }), NullLogger.Instance); private static async Task> CollectAsync( LibraryService service, params string[] folders) { var events = new List(); await foreach (var scanEvent in service.ScanAsync(folders.Length == 0 ? [Root] : folders)) { events.Add(scanEvent); } return events; } } internal static class AsyncEnumerableExtensions { public static async IAsyncEnumerable ToAsyncEnumerable(this IEnumerable source) { foreach (var item in source) { yield return item; } await Task.CompletedTask; } }