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);
}
}
}
}