Implement metadata management features in PLib video library manager. Introduce functionality to query and apply metadata from external sources based on video fingerprints. Update ILibraryService and LibraryService to support metadata lookup and application, enhancing video item descriptions and labels. Revise UI components in VideoPlayerView and SettingsView to facilitate user interaction with metadata sources. Update README.md to document new metadata features and usage instructions.
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user