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
@@ -0,0 +1,137 @@
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using PLib.Infrastructure.Metadata;
using PLib.Infrastructure.Storage;
using Shouldly;
namespace PLib.Tests.Metadata;
/// <summary>
/// A picture host that will not answer must cost a few attempts, not one per candidate.
/// </summary>
public sealed class RemoteImageCacheTests : IDisposable
{
private readonly TempPaths _paths = new();
private readonly CountingHandler _handler = new();
public void Dispose()
{
_paths.Dispose();
_handler.Dispose();
}
[Fact]
public async Task A_host_that_keeps_failing_is_left_alone_after_a_few_tries()
{
var cache = Create();
var problems = new List<string>();
for (var attempt = 0; attempt < 6; attempt++)
{
var image = await cache.GetOrCreateAsync($"https://cdn.example/{attempt}.jpg", Token);
image.Path.ShouldBeNull();
if (image.Problem is { } problem)
{
problems.Add(problem);
}
}
// A library-wide run produces a cover per candidate, all from the same host. Without a
// cut-off, a host that answers its headers and then stalls holds a connection open for
// the full timeout on every single one of them.
_handler.Requests.ShouldBe(3);
// Said once, at the moment the host is given up on. Every miss would put the same line
// beside every candidate; never saying it leaves rows of empty squares unexplained.
problems.ShouldHaveSingleItem().ShouldContain("cdn.example");
}
[Fact]
public async Task A_different_host_is_judged_on_its_own_behaviour()
{
var cache = Create();
for (var attempt = 0; attempt < 4; attempt++)
{
await cache.GetOrCreateAsync($"https://broken.example/{attempt}.jpg", Token);
}
await cache.GetOrCreateAsync("https://other.example/a.jpg", Token);
// Three to the broken host, then it is skipped; the fourth request is the other host.
_handler.Requests.ShouldBe(4);
}
[Fact]
public async Task An_address_that_is_not_a_web_address_is_never_fetched()
{
var cache = Create();
// The URL comes from somebody else's server, so file:// would turn "fetch a picture"
// into "read a path of the server's choosing".
(await cache.GetOrCreateAsync(@"file:///C:/Windows/win.ini", Token)).Path.ShouldBeNull();
(await cache.GetOrCreateAsync("не адрес", Token)).Path.ShouldBeNull();
_handler.Requests.ShouldBe(0);
}
private static CancellationToken Token => TestContext.Current.CancellationToken;
private HttpRemoteImageCache Create()
{
var factory = Substitute.For<IHttpClientFactory>();
factory.CreateClient(Arg.Any<string>()).Returns(_ => new HttpClient(_handler, disposeHandler: false));
return new HttpRemoteImageCache(factory, _paths, NullLogger<HttpRemoteImageCache>.Instance);
}
/// <summary>Fails every request at once, and counts how many it was asked to make.</summary>
private sealed class CountingHandler : HttpMessageHandler
{
private int _requests;
public int Requests => _requests;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Interlocked.Increment(ref _requests);
throw new HttpRequestException("no route to host");
}
}
private sealed class TempPaths : IAppPaths, IDisposable
{
public TempPaths()
{
DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-images-{Guid.CreateVersion7()}");
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
PreviewDirectory = Path.Combine(DataDirectory, "previews");
RemoteImageDirectory = Path.Combine(DataDirectory, "images");
DatabaseFile = Path.Combine(DataDirectory, "library.db");
Directory.CreateDirectory(RemoteImageDirectory);
}
public string DataDirectory { get; }
public string ThumbnailDirectory { get; }
public string PreviewDirectory { get; }
public string RemoteImageDirectory { get; }
public string DatabaseFile { get; }
public void Dispose()
{
if (Directory.Exists(DataDirectory))
{
Directory.Delete(DataDirectory, recursive: true);
}
}
}
}
@@ -51,11 +51,11 @@ public sealed class StashBoxPayloadTests
first.RemoteId.ShouldBe("abc");
first.Title.ShouldBe("Первая");
first.Description.ShouldBe("Описание");
first.Studios.ShouldBe(["Студия"]);
first.Tags.ShouldBe(["драма", "нуар"]);
first.Studios.Select(x => x.Name).ShouldBe(["Студия"]);
first.Tags.Select(x => x.Name).ShouldBe(["драма", "нуар"]);
// The credited alias is not the person; the label has to be the performer's own name.
first.Performers.ShouldBe(["Актёр"]);
first.Performers.Select(x => x.Name).ShouldBe(["Актёр"]);
matches[1].Studios.ShouldBeEmpty();
}
@@ -94,6 +94,119 @@ public sealed class StashBoxPayloadTests
match.Tags.ShouldBeEmpty();
}
[Fact]
public void The_smallest_picture_still_wide_enough_for_a_card_is_the_one_kept()
{
// stash-box returns every size it holds, and the first is not the best: originals run
// to several thousand pixels, and one of those per performer to draw it 150 wide would
// cost megabytes a head.
const string payload = """
{
"data": {
"findSceneByFingerprint": [
{
"id": "abc",
"title": "Сцена",
"studio": { "name": "Студия", "images": [ { "url": "s/4000.jpg", "width": 4000 }, { "url": "s/500.jpg", "width": 500 } ] },
"tags": [ { "name": "драма" } ],
"performers": [
{ "performer": { "name": "Актёр", "images": [
{ "url": "p/2000.jpg", "width": 2000 },
{ "url": "p/400.jpg", "width": 400 },
{ "url": "p/100.jpg", "width": 100 } ] } }
]
}
]
}
}
""";
var match = Read(payload, Flat).ShouldHaveSingleItem();
match.Performers.Single().ImageUrl.ShouldBe("p/400.jpg");
match.Studios.Single().ImageUrl.ShouldBe("s/500.jpg");
// stash-box holds no picture for a tag at all, so there is nothing to find.
match.Tags.Single().ImageUrl.ShouldBeNull();
}
[Fact]
public void The_scene_carries_its_own_cover_apart_from_the_pictures_of_its_people()
{
const string payload = """
{
"data": {
"findSceneByFingerprint": [
{
"id": "abc",
"title": "Сцена",
"images": [ { "url": "scene/1920.jpg", "width": 1920 }, { "url": "scene/640.jpg", "width": 640 } ],
"performers": [ { "performer": { "name": "Актёр", "images": [ { "url": "p/400.jpg", "width": 400 } ] } } ]
}
]
}
}
""";
var match = Read(payload, Flat).ShouldHaveSingleItem();
match.ImageUrl.ShouldBe("scene/640.jpg");
match.Performers.Single().ImageUrl.ShouldBe("p/400.jpg");
// Still a URL, and it stays one: whether to spend the bandwidth is the caller's
// decision, and a candidate must never wait on a picture host to be shown.
match.ImageUrl.ShouldNotBeNull();
}
[Fact]
public void When_every_picture_is_too_small_the_widest_is_taken_rather_than_none()
{
const string payload = """
{
"data": {
"findSceneByFingerprint": [
{
"id": "abc",
"title": "Сцена",
"performers": [
{ "performer": { "name": "Актёр", "images": [
{ "url": "p/80.jpg", "width": 80 },
{ "url": "p/200.jpg", "width": 200 } ] } }
]
}
]
}
}
""";
// A small picture beats an empty card.
Read(payload, Flat).Single().Performers.Single().ImageUrl.ShouldBe("p/200.jpg");
}
[Fact]
public void An_entity_with_no_pictures_at_all_is_still_a_match()
{
const string payload = """
{
"data": {
"findSceneByFingerprint": [
{
"id": "abc",
"title": "Сцена",
"studio": { "name": "Студия", "images": [] },
"performers": [ { "performer": { "name": "Актёр" } } ]
}
]
}
}
""";
var match = Read(payload, Flat).ShouldHaveSingleItem();
match.Studios.Single().ImageUrl.ShouldBeNull();
match.Performers.Single().Name.ShouldBe("Актёр");
}
[Fact]
public void Errors_are_raised_even_though_the_server_answered_two_hundred()
{