Refactor ILibraryService and LibraryService to support new metadata handling features, including remote image management and enhanced label summaries. Update LabelSummary to include ImagePath for better visual representation. Revise MetadataMatchViewModel and MetadataScanViewModel to accommodate new image loading logic. Enhance README.md to document these updates and new functionalities.
This commit is contained in:
@@ -92,6 +92,7 @@ public sealed class DuplicateDetectionTests
|
||||
Substitute.For<IAnimatedPreviewGenerator>(),
|
||||
Substitute.For<IVideoPerceptualHasher>(),
|
||||
Substitute.For<IMetadataProvider>(),
|
||||
Substitute.For<IRemoteImageCache>(),
|
||||
Options.Create(new LibraryOptions()),
|
||||
MetadataMonitor.Empty,
|
||||
NullLogger<LibraryService>.Instance);
|
||||
|
||||
@@ -14,7 +14,7 @@ internal sealed class InMemoryLabelRepository : ILabelRepository
|
||||
|
||||
public Task<IReadOnlyList<LabelSummary>> GetSummariesAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<LabelSummary>>(
|
||||
[.. _labels.Select(label => new LabelSummary(label.Id, label.Name, label.Kind, label.Videos.Count))]);
|
||||
[.. _labels.Select(label => new LabelSummary(label.Id, label.Name, label.Kind, label.Videos.Count, label.ImagePath))]);
|
||||
|
||||
public Task<LibraryLabel?> FindAsync(
|
||||
LabelKind kind,
|
||||
|
||||
@@ -1,84 +1,85 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Domain.Videos;
|
||||
using Shouldly;
|
||||
|
||||
namespace PLib.Tests.Library;
|
||||
|
||||
public sealed class LabelTests
|
||||
{
|
||||
private readonly InMemoryVideoRepository _videos = new();
|
||||
private readonly InMemoryLabelRepository _labels = new();
|
||||
private readonly VideoItem _video = new(@"C:\videos\a.mp4", "a", 1_000, DateTimeOffset.UnixEpoch);
|
||||
|
||||
public LabelTests() => _videos.Seed(_video);
|
||||
|
||||
[Fact]
|
||||
public async Task A_name_used_for_the_first_time_creates_the_label()
|
||||
{
|
||||
var label = await CreateService().AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
||||
|
||||
label.Name.ShouldBe("Комедия");
|
||||
_video.Labels.ShouldHaveSingleItem();
|
||||
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_same_name_in_another_case_reuses_the_label_that_already_exists()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var first = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
||||
var second = await service.AttachLabelAsync(_video.Id, " комедия ", LabelKind.Tag, Token);
|
||||
|
||||
second.Id.ShouldBe(first.Id);
|
||||
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
||||
|
||||
// And attaching it twice must not double it up on the video.
|
||||
_video.Labels.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tag_and_a_collection_may_share_a_name()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var tag = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Tag, Token);
|
||||
var collection = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Collection, Token);
|
||||
|
||||
collection.Id.ShouldNotBe(tag.Id);
|
||||
_video.Labels.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detaching_leaves_the_label_itself_in_the_library()
|
||||
{
|
||||
var service = CreateService();
|
||||
var label = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
||||
|
||||
await service.DetachLabelAsync(_video.Id, label.Id, Token);
|
||||
|
||||
_video.Labels.ShouldBeEmpty();
|
||||
|
||||
// Other videos may still use it, and re-adding must not make a second one.
|
||||
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
private LibraryService CreateService() => new(
|
||||
_videos,
|
||||
_labels,
|
||||
Substitute.For<IVideoFileScanner>(),
|
||||
Substitute.For<IMediaProbe>(),
|
||||
Substitute.For<IThumbnailGenerator>(),
|
||||
Substitute.For<IAnimatedPreviewGenerator>(),
|
||||
Substitute.For<IVideoPerceptualHasher>(),
|
||||
Substitute.For<IMetadataProvider>(),
|
||||
Options.Create(new LibraryOptions()),
|
||||
MetadataMonitor.Empty,
|
||||
NullLogger<LibraryService>.Instance);
|
||||
}
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Domain.Videos;
|
||||
using Shouldly;
|
||||
|
||||
namespace PLib.Tests.Library;
|
||||
|
||||
public sealed class LabelTests
|
||||
{
|
||||
private readonly InMemoryVideoRepository _videos = new();
|
||||
private readonly InMemoryLabelRepository _labels = new();
|
||||
private readonly VideoItem _video = new(@"C:\videos\a.mp4", "a", 1_000, DateTimeOffset.UnixEpoch);
|
||||
|
||||
public LabelTests() => _videos.Seed(_video);
|
||||
|
||||
[Fact]
|
||||
public async Task A_name_used_for_the_first_time_creates_the_label()
|
||||
{
|
||||
var label = await CreateService().AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
||||
|
||||
label.Name.ShouldBe("Комедия");
|
||||
_video.Labels.ShouldHaveSingleItem();
|
||||
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_same_name_in_another_case_reuses_the_label_that_already_exists()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var first = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
||||
var second = await service.AttachLabelAsync(_video.Id, " комедия ", LabelKind.Tag, Token);
|
||||
|
||||
second.Id.ShouldBe(first.Id);
|
||||
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
||||
|
||||
// And attaching it twice must not double it up on the video.
|
||||
_video.Labels.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tag_and_a_collection_may_share_a_name()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var tag = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Tag, Token);
|
||||
var collection = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Collection, Token);
|
||||
|
||||
collection.Id.ShouldNotBe(tag.Id);
|
||||
_video.Labels.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detaching_leaves_the_label_itself_in_the_library()
|
||||
{
|
||||
var service = CreateService();
|
||||
var label = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
|
||||
|
||||
await service.DetachLabelAsync(_video.Id, label.Id, Token);
|
||||
|
||||
_video.Labels.ShouldBeEmpty();
|
||||
|
||||
// Other videos may still use it, and re-adding must not make a second one.
|
||||
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
private LibraryService CreateService() => new(
|
||||
_videos,
|
||||
_labels,
|
||||
Substitute.For<IVideoFileScanner>(),
|
||||
Substitute.For<IMediaProbe>(),
|
||||
Substitute.For<IThumbnailGenerator>(),
|
||||
Substitute.For<IAnimatedPreviewGenerator>(),
|
||||
Substitute.For<IVideoPerceptualHasher>(),
|
||||
Substitute.For<IMetadataProvider>(),
|
||||
Substitute.For<IRemoteImageCache>(),
|
||||
Options.Create(new LibraryOptions()),
|
||||
MetadataMonitor.Empty,
|
||||
NullLogger<LibraryService>.Instance);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class LibraryServiceTests
|
||||
private readonly IAnimatedPreviewGenerator _previews = Substitute.For<IAnimatedPreviewGenerator>();
|
||||
private readonly IVideoPerceptualHasher _hasher = Substitute.For<IVideoPerceptualHasher>();
|
||||
private readonly IMetadataProvider _metadata = Substitute.For<IMetadataProvider>();
|
||||
private readonly IRemoteImageCache _remoteImages = Substitute.For<IRemoteImageCache>();
|
||||
|
||||
public LibraryServiceTests()
|
||||
{
|
||||
@@ -366,9 +367,9 @@ public sealed class LibraryServiceTests
|
||||
"scene-1",
|
||||
"Настоящее название",
|
||||
"Описание",
|
||||
Tags: ["Драма", "драма"],
|
||||
Performers: ["Актёр Один"],
|
||||
Studios: ["Студия"]);
|
||||
Tags: [new("Драма"), new("драма")],
|
||||
Performers: [new("Актёр Один", "https://example/face.jpg")],
|
||||
Studios: [new("Студия")]);
|
||||
|
||||
await CreateService().ApplyMetadataAsync(item.Id, match, Token);
|
||||
|
||||
@@ -381,6 +382,102 @@ public sealed class LibraryServiceTests
|
||||
item.Labels.Single(label => label.Kind == LabelKind.Studio).Name.ShouldBe("Студия");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_picture_is_fetched_for_a_label_that_has_none_and_only_then()
|
||||
{
|
||||
var item = FullyIndexed();
|
||||
_repository.Seed(item);
|
||||
|
||||
_remoteImages.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(RemoteImage.At(@"C:\cache\images\face.jpg"));
|
||||
|
||||
var match = Match("Название", "StashDB") with
|
||||
{
|
||||
Performers = [new MetadataEntity("Актёр", "https://example/face.jpg")],
|
||||
};
|
||||
|
||||
var service = CreateService();
|
||||
await service.ApplyMetadataAsync(item.Id, match, Token);
|
||||
|
||||
var performer = item.Labels.Single(label => label.Kind == LabelKind.Performer);
|
||||
performer.ImagePath.ShouldBe(@"C:\cache\images\face.jpg");
|
||||
|
||||
// Applying again must not go back for it: sources disagree about which photograph
|
||||
// belongs to a performer, and the card would change face on every tagged video.
|
||||
_remoteImages.IsAvailable(@"C:\cache\images\face.jpg").Returns(true);
|
||||
await service.ApplyMetadataAsync(item.Id, match, Token);
|
||||
|
||||
await _remoteImages.Received(1).GetOrCreateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_lookup_hands_back_the_cover_url_without_waiting_on_the_picture_host()
|
||||
{
|
||||
var item = FullyIndexed();
|
||||
_repository.Seed(item);
|
||||
|
||||
var source = Source("StashDB");
|
||||
|
||||
_metadata.FindByPerceptualHashAsync(source, Arg.Any<ulong>(), Arg.Any<CancellationToken>())
|
||||
.Returns([Match("Сцена", source.Name) with { ImageUrl = "https://example/cover.jpg" }]);
|
||||
|
||||
var result = await CreateService(sources: MetadataMonitor.With(source)).FindMetadataAsync(item.Id, Token);
|
||||
|
||||
// Downloading a cover before handing the candidate back put a stranger's picture host
|
||||
// between "we have an answer" and "the user can see it", and one that stalled froze
|
||||
// the whole run with an empty results list.
|
||||
result.Matches.ShouldHaveSingleItem().ImageUrl.ShouldBe("https://example/cover.jpg");
|
||||
await _remoteImages.DidNotReceive().GetOrCreateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fetching_a_picture_is_asked_for_separately_and_answers_with_a_local_path()
|
||||
{
|
||||
_remoteImages.GetOrCreateAsync("https://example/cover.jpg", Arg.Any<CancellationToken>())
|
||||
.Returns(RemoteImage.At(@"C:\cache\images\cover.jpg"));
|
||||
|
||||
var image = await CreateService().FetchImageAsync("https://example/cover.jpg", Token);
|
||||
|
||||
image.Path.ShouldBe(@"C:\cache\images\cover.jpg");
|
||||
image.Problem.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_picture_host_that_has_been_given_up_on_says_so_rather_than_going_quiet()
|
||||
{
|
||||
_remoteImages.GetOrCreateAsync("https://cdn.example/cover.jpg", Arg.Any<CancellationToken>())
|
||||
.Returns(RemoteImage.Unreachable("cdn.example не отдаёт картинки"));
|
||||
|
||||
var image = await CreateService().FetchImageAsync("https://cdn.example/cover.jpg", Token);
|
||||
|
||||
// An empty square looks the same whether the source has no picture or the host is
|
||||
// unreachable, and only one of those is worth putting on screen.
|
||||
image.Path.ShouldBeNull();
|
||||
image.Problem.ShouldBe("cdn.example не отдаёт картинки");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_picture_that_could_not_be_fetched_does_not_fail_the_match()
|
||||
{
|
||||
var item = FullyIndexed();
|
||||
_repository.Seed(item);
|
||||
|
||||
_remoteImages.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns<RemoteImage>(_ => throw new HttpRequestException("503"));
|
||||
|
||||
var match = Match("Название", "StashDB") with
|
||||
{
|
||||
Performers = [new MetadataEntity("Актёр", "https://example/face.jpg")],
|
||||
};
|
||||
|
||||
await CreateService().ApplyMetadataAsync(item.Id, match, Token);
|
||||
|
||||
// The card falls back to an initial, which is a far smaller loss than dropping the
|
||||
// title, the description and every label over one picture.
|
||||
item.Title.ShouldBe("Название");
|
||||
item.Labels.Single(label => label.Kind == LabelKind.Performer).ImagePath.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Applying_a_match_adds_to_the_labels_already_on_the_video()
|
||||
{
|
||||
@@ -390,7 +487,7 @@ public sealed class LibraryServiceTests
|
||||
var service = CreateService();
|
||||
await service.AttachLabelAsync(item.Id, "Моё", LabelKind.Tag, Token);
|
||||
|
||||
await service.ApplyMetadataAsync(item.Id, Match("Название", "StashDB") with { Tags = ["Их"] }, Token);
|
||||
await service.ApplyMetadataAsync(item.Id, Match("Название", "StashDB") with { Tags = [new MetadataEntity("Их")] }, Token);
|
||||
|
||||
// A match is a proposal, not a replacement: what the user put there stays.
|
||||
item.Labels.Select(label => label.Name).ShouldBe(["Моё", "Их"], ignoreOrder: true);
|
||||
@@ -432,6 +529,7 @@ public sealed class LibraryServiceTests
|
||||
_previews,
|
||||
_hasher,
|
||||
_metadata,
|
||||
_remoteImages,
|
||||
Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
|
||||
sources ?? MetadataMonitor.Empty,
|
||||
NullLogger<LibraryService>.Instance);
|
||||
|
||||
@@ -140,6 +140,7 @@ public sealed class MetadataScanTests
|
||||
Substitute.For<IAnimatedPreviewGenerator>(),
|
||||
Substitute.For<IVideoPerceptualHasher>(),
|
||||
_provider,
|
||||
Substitute.For<IRemoteImageCache>(),
|
||||
Options.Create(new LibraryOptions()),
|
||||
|
||||
// No pause between requests: the delay exists to be kind to somebody else's
|
||||
|
||||
Reference in New Issue
Block a user