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,
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;
}
}