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; /// /// The library-wide run. Its job is not to find metadata — that is the provider's — but to /// decide what to ask about, what to write without asking, and when to stop asking a source. /// public sealed class MetadataScanTests { private readonly InMemoryVideoRepository _videos = new(); private readonly InMemoryLabelRepository _labels = new(); private readonly IMetadataProvider _provider = Substitute.For(); private readonly MetadataSourceOptions _source = new() { Name = "StashDB", Endpoint = "https://stashdb.org/graphql" }; [Fact] public async Task Only_fingerprinted_videos_are_asked_about() { var hashed = Video("с отпечатком", hash: 1); _videos.Seed(hashed, Video("без отпечатка", hash: null)); Answer(_source, []); var events = await CollectAsync(); // Without a fingerprint there is no question to ask, so the video is not counted as // processed either — a total that included it would never reach itself. events.OfType().Single().Processed.ShouldBe(1); await _provider.Received(1).FindByPerceptualHashAsync(_source, 1, Arg.Any()); } [Fact] public async Task Videos_a_match_was_already_applied_to_are_skipped_by_default() { var done = Video("размеченное", hash: 1); done.MarkMetadataApplied("StashDB"); _videos.Seed(done, Video("голое", hash: 2)); Answer(_source, []); (await CollectAsync()).OfType().Single().Processed.ShouldBe(1); // Asking again for everything is what the switch is for. var all = await CollectAsync(new MetadataScanRequest(MetadataScanScope.Everything)); all.OfType().Single().Processed.ShouldBe(2); } [Fact] public async Task A_match_with_no_synopsis_still_counts_as_applied() { var video = Video("видео", hash: 1); _videos.Seed(video); // stash-box scenes often carry a title, performers and tags but no details at all. Answer(_source, [Match("Название") with { Description = null }]); await CollectAsync(new MetadataScanRequest(ApplyUnambiguous: true)); video.Description.ShouldBeNull(); video.HasMetadata.ShouldBeTrue(); video.AppliedSources.ShouldHaveSingleItem().SourceName.ShouldBe("StashDB"); // Judging "already done" by the description sent these back on every single run. var again = await CollectAsync(new MetadataScanRequest(ApplyUnambiguous: true)); again.OfType().Single().Processed.ShouldBe(0); } [Fact] public async Task A_result_carries_our_own_poster_frame_for_the_user_to_compare_against() { var video = Video("видео", hash: 1); video.AttachThumbnail(@"C:\cache\ours.jpg"); _videos.Seed(video); Answer(_source, [Match("Название")]); var matched = (await CollectAsync()).OfType().Single(); // Deciding whether a proposal is the same video is done by eye, and there is nothing // to judge against if only the proposals have pictures. matched.VideoThumbnailPath.ShouldBe(@"C:\cache\ours.jpg"); } [Fact] public async Task A_single_candidate_is_written_only_when_the_run_was_told_it_may() { var video = Video("видео", hash: 1); _videos.Seed(video); Answer(_source, [Match("Название")]); await CollectAsync(); video.Description.ShouldBeNull(); var applied = await CollectAsync(new MetadataScanRequest(ApplyUnambiguous: true)); video.Title.ShouldBe("Название"); applied.OfType().Single().Applied.ShouldBe(1); applied.OfType().Single().Applied.ShouldBeTrue(); } [Fact] public async Task Disagreeing_sources_are_never_written_without_asking() { var video = Video("видео", hash: 1); _videos.Seed(video); var second = new MetadataSourceOptions { Name = "Другой", Endpoint = "https://other/graphql" }; Answer(_source, [Match("Одно")]); Answer(second, [Match("Другое")]); var events = await CollectAsync(new MetadataScanRequest(ApplyUnambiguous: true), _source, second); // Two candidates is precisely the case a human has to resolve. video.Title.ShouldBe("видео"); events.OfType().Single().Applied.ShouldBeFalse(); events.OfType().Single().Applied.ShouldBe(0); } [Fact] public async Task A_failing_source_is_reported_once_and_then_left_out_of_the_run() { _videos.Seed(Video("первое", hash: 1), Video("второе", hash: 2), Video("третье", hash: 3)); _provider.FindByPerceptualHashAsync(_source, Arg.Any(), Arg.Any()) .Returns>(_ => throw new HttpRequestException("401")); var events = await CollectAsync(); // A rejected key fails on every video; three identical lines would bury the results, // and three hundred would be the whole page. events.OfType().ShouldHaveSingleItem(); await _provider.Received(1).FindByPerceptualHashAsync( _source, Arg.Any(), Arg.Any()); // The run still finishes, having gone through every video and found nothing. events.OfType().Single().Processed.ShouldBe(3); } [Fact] public async Task A_new_source_can_be_asked_about_videos_the_old_one_already_answered() { var video = Video("видео", hash: 1); video.MarkMetadataApplied("PornDb"); _videos.Seed(video); var fresh = new MetadataSourceOptions { Name = "StashDB", Endpoint = "https://stashdb/graphql" }; var known = new MetadataSourceOptions { Name = "PornDb", Endpoint = "https://porndb/graphql" }; Answer(fresh, [Match("Из нового источника")]); Answer(known, [Match("Из старого источника")]); var events = await CollectAsync( new MetadataScanRequest(MetadataScanScope.MissingSources), known, fresh); // The point of the mode: the source that already had its say is not asked to repeat // itself, and only what the new one adds comes back. var matched = events.OfType().ShouldHaveSingleItem(); matched.Matches.ShouldHaveSingleItem().Title.ShouldBe("Из нового источника"); await _provider.DidNotReceive().FindByPerceptualHashAsync( known, Arg.Any(), Arg.Any()); } [Fact] public async Task A_video_every_source_has_already_answered_is_left_out_of_that_mode() { var video = Video("видео", hash: 1); video.MarkMetadataApplied("StashDB"); _videos.Seed(video); Answer(_source, [Match("Название")]); var events = await CollectAsync(new MetadataScanRequest(MetadataScanScope.MissingSources)); events.OfType().Single().Processed.ShouldBe(0); } [Fact] public async Task Applying_the_same_source_twice_records_it_once() { var video = Video("видео", hash: 1); _videos.Seed(video); video.MarkMetadataApplied("StashDB"); video.MarkMetadataApplied("stashdb"); // Free text the user typed: "PornDb" and "PornDB" are plainly the same source to // everyone but a byte comparison. video.AppliedSources.ShouldHaveSingleItem(); video.HasMetadataFrom("STASHDB").ShouldBeTrue(); await Task.CompletedTask; } private void Answer(MetadataSourceOptions source, IReadOnlyList matches) => _provider.FindByPerceptualHashAsync(source, Arg.Any(), Arg.Any()) .Returns(matches); private static VideoMetadataMatch Match(string title) => new("StashDB", "id", title, "описание", [], [], []); private static VideoItem Video(string title, ulong? hash) { var item = new VideoItem($@"C:\videos\{title}.mp4", title, 1_000, DateTimeOffset.UnixEpoch); item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264")); item.ApplyPerceptualHash(hash); return item; } private async Task> CollectAsync( MetadataScanRequest? request = null, params MetadataSourceOptions[] sources) { var service = new LibraryService( _videos, _labels, Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For(), _provider, Substitute.For(), Options.Create(new LibraryOptions()), // No pause between requests: the delay exists to be kind to somebody else's // server, and there isn't one here. MetadataMonitor.With(sources.Length == 0 ? [_source] : sources, delayMilliseconds: 0), NullLogger.Instance); var events = new List(); await foreach (var scanEvent in service.ScanMetadataAsync( request ?? new MetadataScanRequest(), TestContext.Current.CancellationToken)) { events.Add(scanEvent); } return events; } }