|
|
|
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Options;
|
|
|
|
|
using NSubstitute;
|
|
|
|
|
using PLib.Application.Abstractions;
|
|
|
|
|
using PLib.Application.Library;
|
|
|
|
|
using PLib.Application.Metadata;
|
|
|
|
|
using PLib.Domain.Videos;
|
|
|
|
|
using Shouldly;
|
|
|
|
|
|
|
|
|
@@ -13,11 +14,13 @@ public sealed class LibraryServiceTests
|
|
|
|
|
private const string Root = @"C:\videos";
|
|
|
|
|
|
|
|
|
|
private readonly InMemoryVideoRepository _repository = new();
|
|
|
|
|
private readonly InMemoryLabelRepository _labels = new();
|
|
|
|
|
private readonly IVideoFileScanner _scanner = Substitute.For<IVideoFileScanner>();
|
|
|
|
|
private readonly IMediaProbe _probe = Substitute.For<IMediaProbe>();
|
|
|
|
|
private readonly IThumbnailGenerator _thumbnails = Substitute.For<IThumbnailGenerator>();
|
|
|
|
|
private readonly IAnimatedPreviewGenerator _previews = Substitute.For<IAnimatedPreviewGenerator>();
|
|
|
|
|
private readonly IVideoPerceptualHasher _hasher = Substitute.For<IVideoPerceptualHasher>();
|
|
|
|
|
private readonly IMetadataProvider _metadata = Substitute.For<IMetadataProvider>();
|
|
|
|
|
|
|
|
|
|
public LibraryServiceTests()
|
|
|
|
|
{
|
|
|
|
@@ -298,6 +301,107 @@ public sealed class LibraryServiceTests
|
|
|
|
|
usage[LibraryDataKind.PerceptualHashes].Bytes.ShouldBe(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task A_video_without_a_fingerprint_cannot_be_looked_up()
|
|
|
|
|
{
|
|
|
|
|
var item = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
|
|
|
|
_repository.Seed(item);
|
|
|
|
|
|
|
|
|
|
var result = await CreateService(sources: MetadataMonitor.With(Source("StashDB")))
|
|
|
|
|
.FindMetadataAsync(item.Id, Token);
|
|
|
|
|
|
|
|
|
|
// The answer is "wait for the scan", not "check your sources", so no source is called.
|
|
|
|
|
result.HasPerceptualHash.ShouldBeFalse();
|
|
|
|
|
await _metadata.DidNotReceive().FindByPerceptualHashAsync(
|
|
|
|
|
Arg.Any<MetadataSourceOptions>(),
|
|
|
|
|
Arg.Any<ulong>(),
|
|
|
|
|
Arg.Any<CancellationToken>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task A_source_that_fails_does_not_hide_what_the_others_found()
|
|
|
|
|
{
|
|
|
|
|
var item = FullyIndexed();
|
|
|
|
|
_repository.Seed(item);
|
|
|
|
|
|
|
|
|
|
var good = Source("Хороший");
|
|
|
|
|
var bad = Source("Сломанный");
|
|
|
|
|
|
|
|
|
|
_metadata.FindByPerceptualHashAsync(good, Arg.Any<ulong>(), Arg.Any<CancellationToken>())
|
|
|
|
|
.Returns([Match("Сцена", good.Name)]);
|
|
|
|
|
_metadata.FindByPerceptualHashAsync(bad, Arg.Any<ulong>(), Arg.Any<CancellationToken>())
|
|
|
|
|
.Returns<IReadOnlyList<VideoMetadataMatch>>(_ => throw new HttpRequestException("401"));
|
|
|
|
|
|
|
|
|
|
var result = await CreateService(sources: MetadataMonitor.With(bad, good)).FindMetadataAsync(item.Id, Token);
|
|
|
|
|
|
|
|
|
|
result.Matches.Single().Title.ShouldBe("Сцена");
|
|
|
|
|
result.Failures.ShouldHaveSingleItem().ShouldContain("Сломанный");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Sources_that_are_switched_off_or_half_filled_are_not_called()
|
|
|
|
|
{
|
|
|
|
|
var item = FullyIndexed();
|
|
|
|
|
_repository.Seed(item);
|
|
|
|
|
|
|
|
|
|
var disabled = new MetadataSourceOptions { Name = "Выключен", Endpoint = "https://a/graphql", IsEnabled = false };
|
|
|
|
|
var blank = new MetadataSourceOptions { Name = "Недописан", Endpoint = " " };
|
|
|
|
|
|
|
|
|
|
await CreateService(sources: MetadataMonitor.With(disabled, blank)).FindMetadataAsync(item.Id, Token);
|
|
|
|
|
|
|
|
|
|
await _metadata.DidNotReceive().FindByPerceptualHashAsync(
|
|
|
|
|
Arg.Any<MetadataSourceOptions>(),
|
|
|
|
|
Arg.Any<ulong>(),
|
|
|
|
|
Arg.Any<CancellationToken>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Applying_a_match_writes_the_text_and_a_label_of_the_right_kind_for_each_name()
|
|
|
|
|
{
|
|
|
|
|
var item = FullyIndexed();
|
|
|
|
|
_repository.Seed(item);
|
|
|
|
|
|
|
|
|
|
var match = new VideoMetadataMatch(
|
|
|
|
|
"StashDB",
|
|
|
|
|
"scene-1",
|
|
|
|
|
"Настоящее название",
|
|
|
|
|
"Описание",
|
|
|
|
|
Tags: ["Драма", "драма"],
|
|
|
|
|
Performers: ["Актёр Один"],
|
|
|
|
|
Studios: ["Студия"]);
|
|
|
|
|
|
|
|
|
|
await CreateService().ApplyMetadataAsync(item.Id, match, Token);
|
|
|
|
|
|
|
|
|
|
item.Title.ShouldBe("Настоящее название");
|
|
|
|
|
item.Description.ShouldBe("Описание");
|
|
|
|
|
|
|
|
|
|
// The two spellings of the tag are one label: names are matched case-insensitively.
|
|
|
|
|
item.Labels.Count(label => label.Kind == LabelKind.Tag).ShouldBe(1);
|
|
|
|
|
item.Labels.Single(label => label.Kind == LabelKind.Performer).Name.ShouldBe("Актёр Один");
|
|
|
|
|
item.Labels.Single(label => label.Kind == LabelKind.Studio).Name.ShouldBe("Студия");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Applying_a_match_adds_to_the_labels_already_on_the_video()
|
|
|
|
|
{
|
|
|
|
|
var item = FullyIndexed();
|
|
|
|
|
_repository.Seed(item);
|
|
|
|
|
|
|
|
|
|
var service = CreateService();
|
|
|
|
|
await service.AttachLabelAsync(item.Id, "Моё", LabelKind.Tag, Token);
|
|
|
|
|
|
|
|
|
|
await service.ApplyMetadataAsync(item.Id, Match("Название", "StashDB") with { Tags = ["Их"] }, Token);
|
|
|
|
|
|
|
|
|
|
// A match is a proposal, not a replacement: what the user put there stays.
|
|
|
|
|
item.Labels.Select(label => label.Name).ShouldBe(["Моё", "Их"], ignoreOrder: true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static MetadataSourceOptions Source(string name) =>
|
|
|
|
|
new() { Name = name, Endpoint = $"https://{name}.example/graphql", ApiKey = "key" };
|
|
|
|
|
|
|
|
|
|
private static VideoMetadataMatch Match(string title, string sourceName) =>
|
|
|
|
|
new(sourceName, "id", title, null, [], [], []);
|
|
|
|
|
|
|
|
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
|
|
|
|
|
|
private static VideoItem FullyIndexed()
|
|
|
|
@@ -317,15 +421,19 @@ public sealed class LibraryServiceTests
|
|
|
|
|
_scanner.ScanAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
|
|
|
.Returns(_ => files.ToAsyncEnumerable());
|
|
|
|
|
|
|
|
|
|
private LibraryService CreateService(LibraryOptions? options = null) => new(
|
|
|
|
|
private LibraryService CreateService(
|
|
|
|
|
LibraryOptions? options = null,
|
|
|
|
|
MetadataMonitor? sources = null) => new(
|
|
|
|
|
_repository,
|
|
|
|
|
new InMemoryLabelRepository(),
|
|
|
|
|
_labels,
|
|
|
|
|
_scanner,
|
|
|
|
|
_probe,
|
|
|
|
|
_thumbnails,
|
|
|
|
|
_previews,
|
|
|
|
|
_hasher,
|
|
|
|
|
_metadata,
|
|
|
|
|
Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
|
|
|
|
|
sources ?? MetadataMonitor.Empty,
|
|
|
|
|
NullLogger<LibraryService>.Instance);
|
|
|
|
|
|
|
|
|
|
private static async Task<List<LibraryScanEvent>> CollectAsync(
|
|
|
|
|