Enhance PLib video library manager with new tabbed navigation for entities and metadata management. Introduce sections for videos, tags, actors, studios, collections, and metadata, each with dedicated search and sorting capabilities. Update UI components to reflect these changes, ensuring a cohesive user experience. Revise repository interfaces to support loading video items with labels and summaries for efficient browsing. Update README.md to document new features and usage instructions.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user