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:
Leonid Pershin
2026-08-10 09:25:41 +03:00
parent 9e239e045f
commit b82b0b4555
38 changed files with 4661 additions and 3316 deletions
+102 -4
View File
@@ -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);