using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using PLib.Application.Abstractions; using PLib.Application.Library; using PLib.Application.Metadata; 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 InMemoryLabelRepository _labels = new(); private readonly IVideoFileScanner _scanner = Substitute.For(); private readonly IMediaProbe _probe = Substitute.For(); private readonly IThumbnailGenerator _thumbnails = Substitute.For(); private readonly IAnimatedPreviewGenerator _previews = Substitute.For(); private readonly IVideoPerceptualHasher _hasher = Substitute.For(); private readonly IMetadataProvider _metadata = Substitute.For(); private readonly IRemoteImageCache _remoteImages = 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"); _previews.GetOrCreateAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(callInfo => new AnimatedPreview( $@"C:\cache\{Path.GetFileNameWithoutExtension(callInfo.Arg())}.strip.jpg", 12)); // By default every remembered image is still on disk. _thumbnails.IsAvailable(Arg.Any()).Returns(true); _previews.IsAvailable(Arg.Any()).Returns(true); _hasher.ComputeAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(0xDEADBEEFUL); } [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); // Three passes touch every file: the poster frame, the animated preview, the hash. events.OfType().Count().ShouldBe(6); events.OfType().Single().LibrarySize.ShouldBe(2); } [Fact] public async Task Each_pass_finishes_for_every_file_before_the_next_one_starts() { GivenFilesOnDisk(File(@"C:\videos\a.mp4"), File(@"C:\videos\b.mp4"), File(@"C:\videos\c.mp4")); var events = await CollectAsync(CreateService()); // Cheapest and most visible first: interleaving would hold the poster frame of every // file behind the frame grabs of the one before it. var lastThumbnail = events.FindLastIndex(e => e is LibraryScanEvent.IndexingProgress); var firstPreview = events.FindIndex(e => e is LibraryScanEvent.PreviewProgress); var lastPreview = events.FindLastIndex(e => e is LibraryScanEvent.PreviewProgress); var firstHash = events.FindIndex(e => e is LibraryScanEvent.HashingProgress); firstPreview.ShouldBeGreaterThan(lastThumbnail); firstHash.ShouldBeGreaterThan(lastPreview); } [Fact] public async Task A_file_that_already_has_a_poster_frame_still_gets_a_preview_and_a_hash() { 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()); // Nothing to re-probe, but the later passes have their own work left to do. await _probe.DidNotReceive().ProbeAsync(Arg.Any(), Arg.Any()); _repository.Items.Single().PreviewPath.ShouldNotBeNull(); _repository.Items.Single().PerceptualHash.ShouldNotBeNull(); } [Fact] public async Task An_animated_preview_that_disappeared_from_the_cache_is_rendered_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"); indexed.AttachPreview(@"C:\cache\deleted.strip.jpg", 12); indexed.ApplyPerceptualHash(1); _repository.Seed(indexed); _previews.IsAvailable(@"C:\cache\deleted.strip.jpg").Returns(false); GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000)); await CollectAsync(CreateService()); _repository.Items.Single().PreviewPath.ShouldBe(@"C:\cache\a.strip.jpg"); } [Fact] public async Task A_file_whose_preview_could_not_be_rendered_keeps_the_rest_of_its_indexing() { _previews.GetOrCreateAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns((AnimatedPreview?)null); GivenFilesOnDisk(File(@"C:\videos\a.mp4")); await CollectAsync(CreateService()); // A missing preview only costs the hover animation; the card itself is unaffected. var item = _repository.Items.Single(); item.PreviewPath.ShouldBeNull(); item.IsIndexed.ShouldBeTrue(); item.PerceptualHash.ShouldNotBeNull(); } [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"); indexed.AttachPreview(@"C:\cache\a.strip.jpg", 12); indexed.ApplyPerceptualHash(1); _repository.Seed(indexed); GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000)); await CollectAsync(CreateService()); await _probe.DidNotReceive().ProbeAsync(Arg.Any(), Arg.Any()); await _previews.DidNotReceive() .GetOrCreateAsync(Arg.Any(), 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()); // Every cache is swept, not just the poster frames: previews outlive their videos too. await _previews.Received(1).PurgeUnusedAsync( Arg.Is>(paths => paths != null && paths.SequenceEqual(new[] { @"C:\cache\a.strip.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); } [Fact] public async Task Clearing_one_kind_of_derived_data_leaves_every_other_kind_alone() { var item = FullyIndexed(); _repository.Seed(item); await CreateService().ResetAsync(LibraryDataKind.PerceptualHashes, Token); item.PerceptualHash.ShouldBeNull(); // Clearing the hash is a database edit; nothing on disk has anything to do with it. item.ThumbnailPath.ShouldNotBeNull(); item.PreviewPath.ShouldNotBeNull(); item.Duration.ShouldNotBeNull(); await _thumbnails.DidNotReceive().ClearAsync(Arg.Any()); await _previews.DidNotReceive().ClearAsync(Arg.Any()); } [Fact] public async Task Clearing_an_image_cache_deletes_the_files_as_well_as_the_references() { var item = FullyIndexed(); _repository.Seed(item); await CreateService().ResetAsync(LibraryDataKind.AnimatedPreviews, Token); // A path kept after the file was deleted would leave the card pointing at nothing. item.PreviewPath.ShouldBeNull(); await _previews.Received(1).ClearAsync(Arg.Any()); await _thumbnails.DidNotReceive().ClearAsync(Arg.Any()); item.ThumbnailPath.ShouldNotBeNull(); } [Fact] public async Task Clearing_everything_takes_all_four_kinds() { var item = FullyIndexed(); _repository.Seed(item); await CreateService().ResetAsync(LibraryDataKind.All, Token); item.ThumbnailPath.ShouldBeNull(); item.PreviewPath.ShouldBeNull(); item.PerceptualHash.ShouldBeNull(); item.Duration.ShouldBeNull(); item.IsIndexed.ShouldBeFalse(); } [Fact] public async Task Clearing_one_kind_of_label_leaves_the_other_kinds_standing() { var item = FullyIndexed(); _repository.Seed(item); var service = CreateService(); await service.AttachLabelAsync(item.Id, "драма", LabelKind.Tag, Token); await service.AttachLabelAsync(item.Id, "Актёр", LabelKind.Performer, Token); await service.AttachLabelAsync(item.Id, "Моя подборка", LabelKind.Collection, Token); await service.ResetAsync(LibraryDataKind.Performers, Token); // Gone from the library, not merely detached: the label is the relation, so removing // it takes every attachment with it. var remaining = await service.GetLabelsAsync(Token); remaining.Select(label => label.Kind).ShouldBe([LabelKind.Tag, LabelKind.Collection], ignoreOrder: true); item.Labels.ShouldNotContain(label => label.Kind == LabelKind.Performer); } [Fact] public async Task Clearing_everything_now_takes_the_labels_as_well() { var item = FullyIndexed(); _repository.Seed(item); var service = CreateService(); await service.AttachLabelAsync(item.Id, "драма", LabelKind.Tag, Token); await service.AttachLabelAsync(item.Id, "Студия", LabelKind.Studio, Token); await service.AttachLabelAsync(item.Id, "Моя подборка", LabelKind.Collection, Token); await service.ResetAsync(LibraryDataKind.All, Token); // "Everything" that left the labels behind was the complaint that put them here. (await service.GetLabelsAsync(Token)).ShouldBeEmpty(); item.ThumbnailPath.ShouldBeNull(); item.PerceptualHash.ShouldBeNull(); } [Fact] public async Task Clearing_the_images_does_not_take_the_labels_that_wore_them() { var item = FullyIndexed(); _repository.Seed(item); var service = CreateService(); var performer = await service.AttachLabelAsync(item.Id, "Актёр", LabelKind.Performer, Token); performer.AttachImage(@"C:\cache\images\face.jpg"); await service.ResetAsync(LibraryDataKind.RemoteImages, Token); // The picture is derived; the performer is the thing it was a picture of. (await service.GetLabelsAsync(Token)).ShouldHaveSingleItem().ImagePath.ShouldBeNull(); } [Fact] public async Task Usage_counts_labels_by_kind_without_pretending_they_are_a_share_of_anything() { var item = FullyIndexed(); _repository.Seed(item); var service = CreateService(); await service.AttachLabelAsync(item.Id, "драма", LabelKind.Tag, Token); await service.AttachLabelAsync(item.Id, "нуар", LabelKind.Tag, Token); await service.AttachLabelAsync(item.Id, "Актёр", LabelKind.Performer, Token); var usage = (await service.GetDataUsageAsync(Token)).ToDictionary(entry => entry.Kind); usage[LibraryDataKind.Tags].Present.ShouldBe(2); usage[LibraryDataKind.Performers].Present.ShouldBe(1); // No total: "2 из 2" would only invite the reader to look for the missing ones. usage[LibraryDataKind.Tags].Total.ShouldBeNull(); } [Fact] public async Task Usage_says_how_far_each_kind_has_got_through_the_library() { _repository.Seed(FullyIndexed()); _repository.Seed(new VideoItem(@"C:\videos\b.mp4", "b", 5_000, DateTimeOffset.UnixEpoch)); _previews.GetCacheSizeInBytesAsync(Arg.Any()).Returns(4_096L); var usage = (await CreateService().GetDataUsageAsync(Token)).ToDictionary(entry => entry.Kind); usage[LibraryDataKind.PerceptualHashes].ShouldSatisfyAllConditions( entry => entry.Total.ShouldBe(2), entry => entry.Present.ShouldBe(1)); // Only the caches that are files on disk have a size to report. usage[LibraryDataKind.AnimatedPreviews].Bytes.ShouldBe(4_096L); usage[LibraryDataKind.PerceptualHashes].Bytes.ShouldBe(0); } [Fact] public async Task A_video_without_a_fingerprint_cannot_be_looked_up() { var item = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch); _repository.Seed(item); var result = await CreateService(sources: MetadataMonitor.With(Source("StashDB"))) .FindMetadataAsync(item.Id, Token); // The answer is "wait for the scan", not "check your sources", so no source is called. result.HasPerceptualHash.ShouldBeFalse(); await _metadata.DidNotReceive().FindByPerceptualHashAsync( Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task A_source_that_fails_does_not_hide_what_the_others_found() { var item = FullyIndexed(); _repository.Seed(item); var good = Source("Хороший"); var bad = Source("Сломанный"); _metadata.FindByPerceptualHashAsync(good, Arg.Any(), Arg.Any()) .Returns([Match("Сцена", good.Name)]); _metadata.FindByPerceptualHashAsync(bad, Arg.Any(), Arg.Any()) .Returns>(_ => throw new HttpRequestException("401")); var result = await CreateService(sources: MetadataMonitor.With(bad, good)).FindMetadataAsync(item.Id, Token); result.Matches.Single().Title.ShouldBe("Сцена"); result.Failures.ShouldHaveSingleItem().ShouldContain("Сломанный"); } [Fact] public async Task Sources_that_are_switched_off_or_half_filled_are_not_called() { var item = FullyIndexed(); _repository.Seed(item); var disabled = new MetadataSourceOptions { Name = "Выключен", Endpoint = "https://a/graphql", IsEnabled = false }; var blank = new MetadataSourceOptions { Name = "Недописан", Endpoint = " " }; await CreateService(sources: MetadataMonitor.With(disabled, blank)).FindMetadataAsync(item.Id, Token); await _metadata.DidNotReceive().FindByPerceptualHashAsync( Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task Applying_a_match_writes_the_text_and_a_label_of_the_right_kind_for_each_name() { var item = FullyIndexed(); _repository.Seed(item); var match = new VideoMetadataMatch( "StashDB", "scene-1", "Настоящее название", "Описание", Tags: [new("Драма"), new("драма")], Performers: [new("Актёр Один", "https://example/face.jpg")], Studios: [new("Студия")]); await CreateService().ApplyMetadataAsync(item.Id, match, Token); item.Title.ShouldBe("Настоящее название"); item.Description.ShouldBe("Описание"); // The two spellings of the tag are one label: names are matched case-insensitively. item.Labels.Count(label => label.Kind == LabelKind.Tag).ShouldBe(1); item.Labels.Single(label => label.Kind == LabelKind.Performer).Name.ShouldBe("Актёр Один"); item.Labels.Single(label => label.Kind == LabelKind.Studio).Name.ShouldBe("Студия"); } [Fact] public async Task A_picture_is_fetched_for_a_label_that_has_none_and_only_then() { var item = FullyIndexed(); _repository.Seed(item); _remoteImages.GetOrCreateAsync(Arg.Any(), Arg.Any()) .Returns(RemoteImage.At(@"C:\cache\images\face.jpg")); var match = Match("Название", "StashDB") with { Performers = [new MetadataEntity("Актёр", "https://example/face.jpg")], }; var service = CreateService(); await service.ApplyMetadataAsync(item.Id, match, Token); var performer = item.Labels.Single(label => label.Kind == LabelKind.Performer); performer.ImagePath.ShouldBe(@"C:\cache\images\face.jpg"); // Applying again must not go back for it: sources disagree about which photograph // belongs to a performer, and the card would change face on every tagged video. _remoteImages.IsAvailable(@"C:\cache\images\face.jpg").Returns(true); await service.ApplyMetadataAsync(item.Id, match, Token); await _remoteImages.Received(1).GetOrCreateAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task A_lookup_hands_back_the_cover_url_without_waiting_on_the_picture_host() { var item = FullyIndexed(); _repository.Seed(item); var source = Source("StashDB"); _metadata.FindByPerceptualHashAsync(source, Arg.Any(), Arg.Any()) .Returns([Match("Сцена", source.Name) with { ImageUrl = "https://example/cover.jpg" }]); var result = await CreateService(sources: MetadataMonitor.With(source)).FindMetadataAsync(item.Id, Token); // Downloading a cover before handing the candidate back put a stranger's picture host // between "we have an answer" and "the user can see it", and one that stalled froze // the whole run with an empty results list. result.Matches.ShouldHaveSingleItem().ImageUrl.ShouldBe("https://example/cover.jpg"); await _remoteImages.DidNotReceive().GetOrCreateAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task Fetching_a_picture_is_asked_for_separately_and_answers_with_a_local_path() { _remoteImages.GetOrCreateAsync("https://example/cover.jpg", Arg.Any()) .Returns(RemoteImage.At(@"C:\cache\images\cover.jpg")); var image = await CreateService().FetchImageAsync("https://example/cover.jpg", Token); image.Path.ShouldBe(@"C:\cache\images\cover.jpg"); image.Problem.ShouldBeNull(); } [Fact] public async Task A_picture_host_that_has_been_given_up_on_says_so_rather_than_going_quiet() { _remoteImages.GetOrCreateAsync("https://cdn.example/cover.jpg", Arg.Any()) .Returns(RemoteImage.Unreachable("cdn.example не отдаёт картинки")); var image = await CreateService().FetchImageAsync("https://cdn.example/cover.jpg", Token); // An empty square looks the same whether the source has no picture or the host is // unreachable, and only one of those is worth putting on screen. image.Path.ShouldBeNull(); image.Problem.ShouldBe("cdn.example не отдаёт картинки"); } [Fact] public async Task A_picture_that_could_not_be_fetched_does_not_fail_the_match() { var item = FullyIndexed(); _repository.Seed(item); _remoteImages.GetOrCreateAsync(Arg.Any(), Arg.Any()) .Returns(_ => throw new HttpRequestException("503")); var match = Match("Название", "StashDB") with { Performers = [new MetadataEntity("Актёр", "https://example/face.jpg")], }; await CreateService().ApplyMetadataAsync(item.Id, match, Token); // The card falls back to an initial, which is a far smaller loss than dropping the // title, the description and every label over one picture. item.Title.ShouldBe("Название"); item.Labels.Single(label => label.Kind == LabelKind.Performer).ImagePath.ShouldBeNull(); } [Fact] public async Task Applying_a_match_adds_to_the_labels_already_on_the_video() { var item = FullyIndexed(); _repository.Seed(item); var service = CreateService(); await service.AttachLabelAsync(item.Id, "Моё", LabelKind.Tag, Token); await service.ApplyMetadataAsync(item.Id, Match("Название", "StashDB") with { Tags = [new MetadataEntity("Их")] }, Token); // A match is a proposal, not a replacement: what the user put there stays. item.Labels.Select(label => label.Name).ShouldBe(["Моё", "Их"], ignoreOrder: true); } private static MetadataSourceOptions Source(string name) => new() { Name = name, Endpoint = $"https://{name}.example/graphql", ApiKey = "key" }; private static VideoMetadataMatch Match(string title, string sourceName) => new(sourceName, "id", title, null, [], [], []); private static CancellationToken Token => TestContext.Current.CancellationToken; private static VideoItem FullyIndexed() { var item = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch); item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264")); item.AttachThumbnail(@"C:\cache\a.jpg"); item.AttachPreview(@"C:\cache\a.strip.jpg", 12); item.ApplyPerceptualHash(1); return item; } 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, MetadataMonitor? sources = null) => new( _repository, _labels, _scanner, _probe, _thumbnails, _previews, _hasher, _metadata, _remoteImages, Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }), sources ?? MetadataMonitor.Empty, 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; } }