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
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -201,10 +201,12 @@ public sealed class AppSettingsStoreTests : IDisposable
|
||||
DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}");
|
||||
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
|
||||
PreviewDirectory = Path.Combine(DataDirectory, "previews");
|
||||
RemoteImageDirectory = Path.Combine(DataDirectory, "labels");
|
||||
DatabaseFile = Path.Combine(DataDirectory, "library.db");
|
||||
|
||||
Directory.CreateDirectory(ThumbnailDirectory);
|
||||
Directory.CreateDirectory(PreviewDirectory);
|
||||
Directory.CreateDirectory(RemoteImageDirectory);
|
||||
}
|
||||
|
||||
public string DataDirectory { get; }
|
||||
@@ -213,6 +215,8 @@ public sealed class AppSettingsStoreTests : IDisposable
|
||||
|
||||
public string PreviewDirectory { get; }
|
||||
|
||||
public string RemoteImageDirectory { get; }
|
||||
|
||||
public string DatabaseFile { get; }
|
||||
|
||||
public void Dispose() => Directory.Delete(DataDirectory, recursive: true);
|
||||
|
||||
Reference in New Issue
Block a user