564 lines
23 KiB
C#
564 lines
23 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using PLib.Application.Abstractions;
|
|
using PLib.Application.Library;
|
|
using PLib.Application.Metadata;
|
|
using PLib.Domain.Videos;
|
|
using Shouldly;
|
|
|
|
namespace PLib.Tests.Library;
|
|
|
|
public sealed class LibraryServiceTests
|
|
{
|
|
private const string Root = @"C:\videos";
|
|
|
|
private readonly InMemoryVideoRepository _repository = new();
|
|
private readonly InMemoryLabelRepository _labels = new();
|
|
private readonly IVideoFileScanner _scanner = Substitute.For<IVideoFileScanner>();
|
|
private readonly IMediaProbe _probe = Substitute.For<IMediaProbe>();
|
|
private readonly IThumbnailGenerator _thumbnails = Substitute.For<IThumbnailGenerator>();
|
|
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()
|
|
{
|
|
_probe.ProbeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(new VideoTechnicalInfo(TimeSpan.FromMinutes(2), 1920, 1080, "h264"));
|
|
|
|
_thumbnails.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
|
|
.Returns(callInfo => $@"C:\cache\{Path.GetFileNameWithoutExtension(callInfo.Arg<string>())}.jpg");
|
|
|
|
_previews.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
|
|
.Returns(callInfo => new AnimatedPreview(
|
|
$@"C:\cache\{Path.GetFileNameWithoutExtension(callInfo.Arg<string>())}.strip.jpg",
|
|
12));
|
|
|
|
// By default every remembered image is still on disk.
|
|
_thumbnails.IsAvailable(Arg.Any<string?>()).Returns(true);
|
|
_previews.IsAvailable(Arg.Any<string?>()).Returns(true);
|
|
|
|
_hasher.ComputeAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
|
|
.Returns(0xDEADBEEFUL);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Files_that_are_new_on_disk_are_added_probed_and_given_a_thumbnail()
|
|
{
|
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4"), File(@"C:\videos\b.mkv"));
|
|
|
|
var events = await CollectAsync(CreateService());
|
|
|
|
_repository.Items.Count.ShouldBe(2);
|
|
_repository.Items.ShouldAllBe(x => x.IsIndexed);
|
|
|
|
events.OfType<LibraryScanEvent.ItemAdded>().Count().ShouldBe(2);
|
|
|
|
// Three passes touch every file: the poster frame, the animated preview, the hash.
|
|
events.OfType<LibraryScanEvent.ItemUpdated>().Count().ShouldBe(6);
|
|
events.OfType<LibraryScanEvent.Completed>().Single().LibrarySize.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Each_pass_finishes_for_every_file_before_the_next_one_starts()
|
|
{
|
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4"), File(@"C:\videos\b.mp4"), File(@"C:\videos\c.mp4"));
|
|
|
|
var events = await CollectAsync(CreateService());
|
|
|
|
// Cheapest and most visible first: interleaving would hold the poster frame of every
|
|
// file behind the frame grabs of the one before it.
|
|
var lastThumbnail = events.FindLastIndex(e => e is LibraryScanEvent.IndexingProgress);
|
|
var firstPreview = events.FindIndex(e => e is LibraryScanEvent.PreviewProgress);
|
|
var lastPreview = events.FindLastIndex(e => e is LibraryScanEvent.PreviewProgress);
|
|
var firstHash = events.FindIndex(e => e is LibraryScanEvent.HashingProgress);
|
|
|
|
firstPreview.ShouldBeGreaterThan(lastThumbnail);
|
|
firstHash.ShouldBeGreaterThan(lastPreview);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_file_that_already_has_a_poster_frame_still_gets_a_preview_and_a_hash()
|
|
{
|
|
var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
|
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
|
indexed.AttachThumbnail(@"C:\cache\a.jpg");
|
|
_repository.Seed(indexed);
|
|
|
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000));
|
|
|
|
await CollectAsync(CreateService());
|
|
|
|
// Nothing to re-probe, but the later passes have their own work left to do.
|
|
await _probe.DidNotReceive().ProbeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
|
_repository.Items.Single().PreviewPath.ShouldNotBeNull();
|
|
_repository.Items.Single().PerceptualHash.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_animated_preview_that_disappeared_from_the_cache_is_rendered_again()
|
|
{
|
|
var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
|
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
|
indexed.AttachThumbnail(@"C:\cache\a.jpg");
|
|
indexed.AttachPreview(@"C:\cache\deleted.strip.jpg", 12);
|
|
indexed.ApplyPerceptualHash(1);
|
|
_repository.Seed(indexed);
|
|
|
|
_previews.IsAvailable(@"C:\cache\deleted.strip.jpg").Returns(false);
|
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000));
|
|
|
|
await CollectAsync(CreateService());
|
|
|
|
_repository.Items.Single().PreviewPath.ShouldBe(@"C:\cache\a.strip.jpg");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_file_whose_preview_could_not_be_rendered_keeps_the_rest_of_its_indexing()
|
|
{
|
|
_previews.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
|
|
.Returns((AnimatedPreview?)null);
|
|
|
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4"));
|
|
|
|
await CollectAsync(CreateService());
|
|
|
|
// A missing preview only costs the hover animation; the card itself is unaffected.
|
|
var item = _repository.Items.Single();
|
|
item.PreviewPath.ShouldBeNull();
|
|
item.IsIndexed.ShouldBeTrue();
|
|
item.PerceptualHash.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Entries_whose_file_is_gone_are_dropped_from_the_library()
|
|
{
|
|
_repository.Seed(new VideoItem(@"C:\videos\stale.mp4", "stale", 5_000, DateTimeOffset.UnixEpoch));
|
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4"));
|
|
|
|
var events = await CollectAsync(CreateService());
|
|
|
|
_repository.Items.Select(x => x.FullPath).ShouldBe([@"C:\videos\a.mp4"]);
|
|
events.OfType<LibraryScanEvent.ItemRemoved>().Count().ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Files_below_the_minimum_size_are_not_part_of_the_library()
|
|
{
|
|
GivenFilesOnDisk(File(@"C:\videos\tiny.mp4", sizeInBytes: 128), File(@"C:\videos\real.mp4"));
|
|
|
|
await CollectAsync(CreateService(new LibraryOptions { MinimumFileSizeInBytes = 1_024 }));
|
|
|
|
_repository.Items.Select(x => x.FullPath).ShouldBe([@"C:\videos\real.mp4"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_item_that_is_already_indexed_is_not_probed_again()
|
|
{
|
|
var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
|
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
|
indexed.AttachThumbnail(@"C:\cache\a.jpg");
|
|
indexed.AttachPreview(@"C:\cache\a.strip.jpg", 12);
|
|
indexed.ApplyPerceptualHash(1);
|
|
_repository.Seed(indexed);
|
|
|
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000));
|
|
|
|
await CollectAsync(CreateService());
|
|
|
|
await _probe.DidNotReceive().ProbeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
|
await _previews.DidNotReceive()
|
|
.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_item_whose_file_changed_on_disk_is_indexed_again()
|
|
{
|
|
var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
|
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
|
indexed.AttachThumbnail(@"C:\cache\old.jpg");
|
|
_repository.Seed(indexed);
|
|
|
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 9_999));
|
|
|
|
await CollectAsync(CreateService());
|
|
|
|
await _probe.Received(1).ProbeAsync(@"C:\videos\a.mp4", Arg.Any<CancellationToken>());
|
|
_repository.Items.Single().ThumbnailPath.ShouldBe(@"C:\cache\a.jpg");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_poster_frame_that_disappeared_from_the_cache_is_generated_again()
|
|
{
|
|
var indexed = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
|
indexed.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
|
indexed.AttachThumbnail(@"C:\cache\deleted.jpg");
|
|
_repository.Seed(indexed);
|
|
|
|
_thumbnails.IsAvailable(@"C:\cache\deleted.jpg").Returns(false);
|
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4", sizeInBytes: 5_000));
|
|
|
|
await CollectAsync(CreateService());
|
|
|
|
await _thumbnails.Received(1)
|
|
.GetOrCreateAsync(@"C:\videos\a.mp4", Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>());
|
|
_repository.Items.Single().ThumbnailPath.ShouldBe(@"C:\cache\a.jpg");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cached_frames_nothing_points_at_are_purged_once_the_scan_is_whole()
|
|
{
|
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4"));
|
|
|
|
await CollectAsync(CreateService());
|
|
|
|
await _thumbnails.Received(1).PurgeUnusedAsync(
|
|
Arg.Is<IReadOnlyCollection<string>>(paths => paths != null && paths.SequenceEqual(new[] { @"C:\cache\a.jpg" })),
|
|
Arg.Any<CancellationToken>());
|
|
|
|
// Every cache is swept, not just the poster frames: previews outlive their videos too.
|
|
await _previews.Received(1).PurgeUnusedAsync(
|
|
Arg.Is<IReadOnlyCollection<string>>(paths => paths != null && paths.SequenceEqual(new[] { @"C:\cache\a.strip.jpg" })),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_same_file_reached_through_two_overlapping_roots_is_only_added_once()
|
|
{
|
|
GivenFilesOnDisk(File(@"C:\videos\a.mp4"));
|
|
|
|
var events = await CollectAsync(CreateService(), Root, Root);
|
|
|
|
_repository.Items.Count.ShouldBe(1);
|
|
events.OfType<LibraryScanEvent.DiscoveryCompleted>().Single().FilesFound.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Clearing_one_kind_of_derived_data_leaves_every_other_kind_alone()
|
|
{
|
|
var item = FullyIndexed();
|
|
_repository.Seed(item);
|
|
|
|
await CreateService().ResetAsync(LibraryDataKind.PerceptualHashes, Token);
|
|
|
|
item.PerceptualHash.ShouldBeNull();
|
|
|
|
// Clearing the hash is a database edit; nothing on disk has anything to do with it.
|
|
item.ThumbnailPath.ShouldNotBeNull();
|
|
item.PreviewPath.ShouldNotBeNull();
|
|
item.Duration.ShouldNotBeNull();
|
|
await _thumbnails.DidNotReceive().ClearAsync(Arg.Any<CancellationToken>());
|
|
await _previews.DidNotReceive().ClearAsync(Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Clearing_an_image_cache_deletes_the_files_as_well_as_the_references()
|
|
{
|
|
var item = FullyIndexed();
|
|
_repository.Seed(item);
|
|
|
|
await CreateService().ResetAsync(LibraryDataKind.AnimatedPreviews, Token);
|
|
|
|
// A path kept after the file was deleted would leave the card pointing at nothing.
|
|
item.PreviewPath.ShouldBeNull();
|
|
await _previews.Received(1).ClearAsync(Arg.Any<CancellationToken>());
|
|
await _thumbnails.DidNotReceive().ClearAsync(Arg.Any<CancellationToken>());
|
|
item.ThumbnailPath.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Clearing_everything_takes_all_four_kinds()
|
|
{
|
|
var item = FullyIndexed();
|
|
_repository.Seed(item);
|
|
|
|
await CreateService().ResetAsync(LibraryDataKind.All, Token);
|
|
|
|
item.ThumbnailPath.ShouldBeNull();
|
|
item.PreviewPath.ShouldBeNull();
|
|
item.PerceptualHash.ShouldBeNull();
|
|
item.Duration.ShouldBeNull();
|
|
item.IsIndexed.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Usage_says_how_far_each_kind_has_got_through_the_library()
|
|
{
|
|
_repository.Seed(FullyIndexed());
|
|
_repository.Seed(new VideoItem(@"C:\videos\b.mp4", "b", 5_000, DateTimeOffset.UnixEpoch));
|
|
|
|
_previews.GetCacheSizeInBytesAsync(Arg.Any<CancellationToken>()).Returns(4_096L);
|
|
|
|
var usage = (await CreateService().GetDataUsageAsync(Token)).ToDictionary(entry => entry.Kind);
|
|
|
|
usage[LibraryDataKind.PerceptualHashes].ShouldSatisfyAllConditions(
|
|
entry => entry.Videos.ShouldBe(2),
|
|
entry => entry.Present.ShouldBe(1));
|
|
|
|
// Only the caches that are files on disk have a size to report.
|
|
usage[LibraryDataKind.AnimatedPreviews].Bytes.ShouldBe(4_096L);
|
|
usage[LibraryDataKind.PerceptualHashes].Bytes.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_video_without_a_fingerprint_cannot_be_looked_up()
|
|
{
|
|
var item = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
|
_repository.Seed(item);
|
|
|
|
var result = await CreateService(sources: MetadataMonitor.With(Source("StashDB")))
|
|
.FindMetadataAsync(item.Id, Token);
|
|
|
|
// The answer is "wait for the scan", not "check your sources", so no source is called.
|
|
result.HasPerceptualHash.ShouldBeFalse();
|
|
await _metadata.DidNotReceive().FindByPerceptualHashAsync(
|
|
Arg.Any<MetadataSourceOptions>(),
|
|
Arg.Any<ulong>(),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_source_that_fails_does_not_hide_what_the_others_found()
|
|
{
|
|
var item = FullyIndexed();
|
|
_repository.Seed(item);
|
|
|
|
var good = Source("Хороший");
|
|
var bad = Source("Сломанный");
|
|
|
|
_metadata.FindByPerceptualHashAsync(good, Arg.Any<ulong>(), Arg.Any<CancellationToken>())
|
|
.Returns([Match("Сцена", good.Name)]);
|
|
_metadata.FindByPerceptualHashAsync(bad, Arg.Any<ulong>(), Arg.Any<CancellationToken>())
|
|
.Returns<IReadOnlyList<VideoMetadataMatch>>(_ => throw new HttpRequestException("401"));
|
|
|
|
var result = await CreateService(sources: MetadataMonitor.With(bad, good)).FindMetadataAsync(item.Id, Token);
|
|
|
|
result.Matches.Single().Title.ShouldBe("Сцена");
|
|
result.Failures.ShouldHaveSingleItem().ShouldContain("Сломанный");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Sources_that_are_switched_off_or_half_filled_are_not_called()
|
|
{
|
|
var item = FullyIndexed();
|
|
_repository.Seed(item);
|
|
|
|
var disabled = new MetadataSourceOptions { Name = "Выключен", Endpoint = "https://a/graphql", IsEnabled = false };
|
|
var blank = new MetadataSourceOptions { Name = "Недописан", Endpoint = " " };
|
|
|
|
await CreateService(sources: MetadataMonitor.With(disabled, blank)).FindMetadataAsync(item.Id, Token);
|
|
|
|
await _metadata.DidNotReceive().FindByPerceptualHashAsync(
|
|
Arg.Any<MetadataSourceOptions>(),
|
|
Arg.Any<ulong>(),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Applying_a_match_writes_the_text_and_a_label_of_the_right_kind_for_each_name()
|
|
{
|
|
var item = FullyIndexed();
|
|
_repository.Seed(item);
|
|
|
|
var match = new VideoMetadataMatch(
|
|
"StashDB",
|
|
"scene-1",
|
|
"Настоящее название",
|
|
"Описание",
|
|
Tags: [new("Драма"), new("драма")],
|
|
Performers: [new("Актёр Один", "https://example/face.jpg")],
|
|
Studios: [new("Студия")]);
|
|
|
|
await CreateService().ApplyMetadataAsync(item.Id, match, Token);
|
|
|
|
item.Title.ShouldBe("Настоящее название");
|
|
item.Description.ShouldBe("Описание");
|
|
|
|
// The two spellings of the tag are one label: names are matched case-insensitively.
|
|
item.Labels.Count(label => label.Kind == LabelKind.Tag).ShouldBe(1);
|
|
item.Labels.Single(label => label.Kind == LabelKind.Performer).Name.ShouldBe("Актёр Один");
|
|
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()
|
|
{
|
|
var item = FullyIndexed();
|
|
_repository.Seed(item);
|
|
|
|
var service = CreateService();
|
|
await service.AttachLabelAsync(item.Id, "Моё", LabelKind.Tag, 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);
|
|
}
|
|
|
|
private static MetadataSourceOptions Source(string name) =>
|
|
new() { Name = name, Endpoint = $"https://{name}.example/graphql", ApiKey = "key" };
|
|
|
|
private static VideoMetadataMatch Match(string title, string sourceName) =>
|
|
new(sourceName, "id", title, null, [], [], []);
|
|
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
private static VideoItem FullyIndexed()
|
|
{
|
|
var item = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
|
item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
|
item.AttachThumbnail(@"C:\cache\a.jpg");
|
|
item.AttachPreview(@"C:\cache\a.strip.jpg", 12);
|
|
item.ApplyPerceptualHash(1);
|
|
return item;
|
|
}
|
|
|
|
private static DiscoveredVideoFile File(string path, long sizeInBytes = 10_000) =>
|
|
new(path, sizeInBytes, DateTimeOffset.UnixEpoch);
|
|
|
|
private void GivenFilesOnDisk(params DiscoveredVideoFile[] files) =>
|
|
_scanner.ScanAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
|
.Returns(_ => files.ToAsyncEnumerable());
|
|
|
|
private LibraryService CreateService(
|
|
LibraryOptions? options = null,
|
|
MetadataMonitor? sources = null) => new(
|
|
_repository,
|
|
_labels,
|
|
_scanner,
|
|
_probe,
|
|
_thumbnails,
|
|
_previews,
|
|
_hasher,
|
|
_metadata,
|
|
_remoteImages,
|
|
Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
|
|
sources ?? MetadataMonitor.Empty,
|
|
NullLogger<LibraryService>.Instance);
|
|
|
|
private static async Task<List<LibraryScanEvent>> CollectAsync(
|
|
LibraryService service,
|
|
params string[] folders)
|
|
{
|
|
var events = new List<LibraryScanEvent>();
|
|
|
|
await foreach (var scanEvent in service.ScanAsync(folders.Length == 0 ? [Root] : folders))
|
|
{
|
|
events.Add(scanEvent);
|
|
}
|
|
|
|
return events;
|
|
}
|
|
}
|
|
|
|
internal static class AsyncEnumerableExtensions
|
|
{
|
|
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IEnumerable<T> source)
|
|
{
|
|
foreach (var item in source)
|
|
{
|
|
yield return item;
|
|
}
|
|
|
|
await Task.CompletedTask;
|
|
}
|
|
}
|