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:
Leonid Pershin
2026-08-09 08:18:40 +03:00
parent c41a458d0b
commit eb4894428b
31 changed files with 1442 additions and 194 deletions
@@ -91,6 +91,8 @@ public sealed class DuplicateDetectionTests
Substitute.For<IThumbnailGenerator>(),
Substitute.For<IAnimatedPreviewGenerator>(),
Substitute.For<IVideoPerceptualHasher>(),
Substitute.For<IMetadataProvider>(),
Options.Create(new LibraryOptions()),
MetadataMonitor.Empty,
NullLogger<LibraryService>.Instance);
}
+2
View File
@@ -77,6 +77,8 @@ public sealed class LabelTests
Substitute.For<IThumbnailGenerator>(),
Substitute.For<IAnimatedPreviewGenerator>(),
Substitute.For<IVideoPerceptualHasher>(),
Substitute.For<IMetadataProvider>(),
Options.Create(new LibraryOptions()),
MetadataMonitor.Empty,
NullLogger<LibraryService>.Instance);
}
+110 -2
View File
@@ -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(
@@ -0,0 +1,26 @@
using Microsoft.Extensions.Options;
using PLib.Application.Metadata;
namespace PLib.Tests.Library;
/// <summary>
/// A fixed <see cref="IOptionsMonitor{T}"/> over metadata sources.
/// </summary>
/// <remarks>
/// The service reads <c>CurrentValue</c> on every lookup, because the user can add a source
/// without restarting. A substitute would answer null and blow up in the one place that
/// matters, so the tests hand it a real value instead.
/// </remarks>
internal sealed class MetadataMonitor(MetadataOptions value) : IOptionsMonitor<MetadataOptions>
{
public static MetadataMonitor Empty { get; } = new(new MetadataOptions());
public static MetadataMonitor With(params MetadataSourceOptions[] sources) =>
new(new MetadataOptions { Sources = [.. sources] });
public MetadataOptions CurrentValue { get; } = value;
public MetadataOptions Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<MetadataOptions, string?> listener) => null;
}