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