Files
PLib/tests/PLib.Tests/Library/MetadataScanTests.cs
T

162 lines
6.4 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
public sealed class MetadataScanTests
{
private readonly InMemoryVideoRepository _videos = new();
private readonly InMemoryLabelRepository _labels = new();
private readonly IMetadataProvider _provider = Substitute.For<IMetadataProvider>();
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<MetadataScanEvent.Completed>().Single().Processed.ShouldBe(1);
await _provider.Received(1).FindByPerceptualHashAsync(_source, 1, Arg.Any<CancellationToken>());
}
[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<MetadataScanEvent.Completed>().Single().Processed.ShouldBe(1);
// Asking again for everything is what the switch is for.
var all = await CollectAsync(new MetadataScanRequest(OnlyWithoutDescription: false));
all.OfType<MetadataScanEvent.Completed>().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<MetadataScanEvent.Completed>().Single().Applied.ShouldBe(1);
applied.OfType<MetadataScanEvent.Matched>().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<MetadataScanEvent.Matched>().Single().Applied.ShouldBeFalse();
events.OfType<MetadataScanEvent.Completed>().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<ulong>(), Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<VideoMetadataMatch>>(_ => 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<MetadataScanEvent.SourceAbandoned>().ShouldHaveSingleItem();
await _provider.Received(1).FindByPerceptualHashAsync(
_source,
Arg.Any<ulong>(),
Arg.Any<CancellationToken>());
// The run still finishes, having gone through every video and found nothing.
events.OfType<MetadataScanEvent.Completed>().Single().Processed.ShouldBe(3);
}
private void Answer(MetadataSourceOptions source, IReadOnlyList<VideoMetadataMatch> matches) =>
_provider.FindByPerceptualHashAsync(source, Arg.Any<ulong>(), Arg.Any<CancellationToken>())
.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<List<MetadataScanEvent>> CollectAsync(
MetadataScanRequest? request = null,
params MetadataSourceOptions[] sources)
{
var service = new LibraryService(
_videos,
_labels,
Substitute.For<IVideoFileScanner>(),
Substitute.For<IMediaProbe>(),
Substitute.For<IThumbnailGenerator>(),
Substitute.For<IAnimatedPreviewGenerator>(),
Substitute.For<IVideoPerceptualHasher>(),
_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<LibraryService>.Instance);
var events = new List<MetadataScanEvent>();
await foreach (var scanEvent in service.ScanMetadataAsync(
request ?? new MetadataScanRequest(),
TestContext.Current.CancellationToken))
{
events.Add(scanEvent);
}
return events;
}
}