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_that_already_have_a_description_are_skipped_by_default() { var described = Video("описанное", hash: 1); described.Describe("уже есть"); _videos.Seed(described, 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(OnlyWithoutDescription: false)); all.OfType().Single().Processed.ShouldBe(2); } [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); } 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, 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; } }