Implement animated previews and perceptual hashing in PLib video library manager. Introduce IAnimatedPreviewGenerator and IVideoPerceptualHasher interfaces, enhancing video item metadata with animated preview paths and perceptual hashes. Update LibraryService to manage indexing in three passes: metadata, animated previews, and perceptual hashes. Revise UI components to display animated previews and manage duplicate video detection. Enhance README.md to document these new features and usage instructions.

This commit is contained in:
Leonid Pershin
2026-08-09 07:48:45 +03:00
parent 10c66baea8
commit 3c77baced7
36 changed files with 2711 additions and 471 deletions
+101 -64
View File
@@ -1,64 +1,101 @@
using PLib.Domain.Videos;
using Shouldly;
namespace PLib.Tests.Domain;
public sealed class VideoItemTests
{
private static VideoItem CreateIndexedItem()
{
var item = new VideoItem(@"C:\videos\clip.mp4", "clip", 1_000, DateTimeOffset.UnixEpoch);
item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(3), 1920, 1080, "h264"));
item.AttachThumbnail(@"C:\cache\clip.jpg");
return item;
}
[Fact]
public void An_item_is_indexed_only_once_it_has_both_a_duration_and_a_thumbnail()
{
var item = new VideoItem(@"C:\videos\clip.mp4", "clip", 1_000, DateTimeOffset.UnixEpoch);
item.IsIndexed.ShouldBeFalse();
item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
item.IsIndexed.ShouldBeFalse();
item.AttachThumbnail(@"C:\cache\clip.jpg");
item.IsIndexed.ShouldBeTrue();
}
[Fact]
public void Refreshing_an_unchanged_file_keeps_everything_that_was_derived_from_it()
{
var item = CreateIndexedItem();
item.RefreshFileFacts(1_000, DateTimeOffset.UnixEpoch);
item.IsIndexed.ShouldBeTrue();
item.ThumbnailPath.ShouldNotBeNull();
}
[Fact]
public void Refreshing_a_changed_file_invalidates_the_metadata_and_the_thumbnail()
{
var item = CreateIndexedItem();
item.RefreshFileFacts(2_000, DateTimeOffset.UnixEpoch.AddDays(1));
item.SizeInBytes.ShouldBe(2_000);
item.Duration.ShouldBeNull();
item.ThumbnailPath.ShouldBeNull();
item.IsIndexed.ShouldBeFalse();
}
[Theory]
[InlineData("", "title")]
[InlineData(" ", "title")]
[InlineData(@"C:\videos\clip.mp4", "")]
public void An_item_cannot_be_created_without_a_path_and_a_title(string path, string title) =>
Should.Throw<ArgumentException>(() => new VideoItem(path, title, 1, DateTimeOffset.UnixEpoch));
[Fact]
public void An_item_cannot_have_a_negative_size() =>
Should.Throw<ArgumentOutOfRangeException>(
() => new VideoItem(@"C:\videos\clip.mp4", "clip", -1, DateTimeOffset.UnixEpoch));
}
using PLib.Domain.Videos;
using Shouldly;
namespace PLib.Tests.Domain;
public sealed class VideoItemTests
{
private static VideoItem CreateIndexedItem()
{
var item = new VideoItem(@"C:\videos\clip.mp4", "clip", 1_000, DateTimeOffset.UnixEpoch);
item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(3), 1920, 1080, "h264"));
item.AttachThumbnail(@"C:\cache\clip.jpg");
item.AttachPreview(@"C:\cache\clip.strip.jpg", 12);
item.ApplyPerceptualHash(0xDEADBEEF);
return item;
}
[Fact]
public void An_item_is_indexed_once_it_has_a_duration_and_a_poster_frame()
{
var item = new VideoItem(@"C:\videos\clip.mp4", "clip", 1_000, DateTimeOffset.UnixEpoch);
item.IsIndexed.ShouldBeFalse();
item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
item.IsIndexed.ShouldBeFalse();
item.AttachThumbnail(@"C:\cache\clip.jpg");
// Neither the animated preview nor the hash is part of this: both belong to later
// passes, and the grid is usable long before either exists.
item.IsIndexed.ShouldBeTrue();
item.NeedsAnimatedPreview.ShouldBeTrue();
item.NeedsPerceptualHash.ShouldBeTrue();
item.AttachPreview(@"C:\cache\clip.strip.jpg", 12);
item.NeedsAnimatedPreview.ShouldBeFalse();
item.ApplyPerceptualHash(1);
item.NeedsPerceptualHash.ShouldBeFalse();
}
[Fact]
public void The_later_passes_cannot_be_asked_for_before_the_duration_is_known()
{
var item = new VideoItem(@"C:\videos\clip.mp4", "clip", 1_000, DateTimeOffset.UnixEpoch);
// Both sample frames across the running time, so there is nothing to sample yet.
item.NeedsAnimatedPreview.ShouldBeFalse();
item.NeedsPerceptualHash.ShouldBeFalse();
}
[Fact]
public void A_preview_of_a_single_frame_is_not_an_animation()
{
var item = new VideoItem(@"C:\videos\clip.mp4", "clip", 1_000, DateTimeOffset.UnixEpoch);
Should.Throw<ArgumentOutOfRangeException>(() => item.AttachPreview(@"C:\cache\clip.jpg", 1));
}
[Fact]
public void Refreshing_an_unchanged_file_keeps_everything_that_was_derived_from_it()
{
var item = CreateIndexedItem();
item.RefreshFileFacts(1_000, DateTimeOffset.UnixEpoch);
item.IsIndexed.ShouldBeTrue();
item.ThumbnailPath.ShouldNotBeNull();
item.PreviewPath.ShouldNotBeNull();
}
[Fact]
public void Refreshing_a_changed_file_invalidates_the_metadata_and_the_thumbnail()
{
var item = CreateIndexedItem();
item.RefreshFileFacts(2_000, DateTimeOffset.UnixEpoch.AddDays(1));
item.SizeInBytes.ShouldBe(2_000);
item.Duration.ShouldBeNull();
item.ThumbnailPath.ShouldBeNull();
item.PreviewPath.ShouldBeNull();
item.PreviewFrameCount.ShouldBe(0);
// The hash describes the picture, so a different file means a different hash.
item.PerceptualHash.ShouldBeNull();
item.IsIndexed.ShouldBeFalse();
}
[Theory]
[InlineData("", "title")]
[InlineData(" ", "title")]
[InlineData(@"C:\videos\clip.mp4", "")]
public void An_item_cannot_be_created_without_a_path_and_a_title(string path, string title) =>
Should.Throw<ArgumentException>(() => new VideoItem(path, title, 1, DateTimeOffset.UnixEpoch));
[Fact]
public void An_item_cannot_have_a_negative_size() =>
Should.Throw<ArgumentOutOfRangeException>(
() => new VideoItem(@"C:\videos\clip.mp4", "clip", -1, DateTimeOffset.UnixEpoch));
}
@@ -0,0 +1,96 @@
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 DuplicateDetectionTests
{
private readonly InMemoryVideoRepository _videos = new();
[Fact]
public void Distance_counts_the_bits_that_differ()
{
var left = Video("a", 0b1111);
var right = Video("b", 0b1010);
left.DistanceTo(right).ShouldBe(2);
}
[Fact]
public void A_video_without_a_hash_has_no_distance_to_anything()
{
var hashed = Video("a", 1);
var bare = new VideoItem(@"C:\videos\b.mp4", "b", 1, DateTimeOffset.UnixEpoch);
hashed.DistanceTo(bare).ShouldBeNull();
bare.DistanceTo(hashed).ShouldBeNull();
}
[Fact]
public async Task Near_identical_videos_are_grouped_and_unrelated_ones_are_not()
{
_videos.Seed(
Video("original", 0b0000_0000),
Video("re-encode", 0b0000_0011),
Video("unrelated", 0b1111_1111));
var groups = await CreateService().FindDuplicatesAsync(maxDistance: 4, Token);
groups.ShouldHaveSingleItem();
groups[0].Select(x => x.Title).OrderBy(x => x).ShouldBe(["original", "re-encode"]);
}
[Fact]
public async Task A_chain_of_near_matches_ends_up_in_one_group()
{
// Ends are 4 bits apart — further than the threshold — but the middle copy links
// them, and all three are the same film.
_videos.Seed(
Video("a", 0b0000_0000),
Video("b", 0b0000_0011),
Video("c", 0b0000_1111));
var groups = await CreateService().FindDuplicatesAsync(maxDistance: 2, Token);
groups.ShouldHaveSingleItem();
groups[0].Count.ShouldBe(3);
}
[Fact]
public async Task Videos_that_were_never_hashed_are_left_out_entirely()
{
_videos.Seed(
new VideoItem(@"C:\videos\x.mp4", "x", 1, DateTimeOffset.UnixEpoch),
new VideoItem(@"C:\videos\y.mp4", "y", 1, DateTimeOffset.UnixEpoch));
var groups = await CreateService().FindDuplicatesAsync(maxDistance: 64, Token);
// Two hashless videos are not evidence of anything, however wide the threshold.
groups.ShouldBeEmpty();
}
private static CancellationToken Token => TestContext.Current.CancellationToken;
private static VideoItem Video(string title, ulong hash)
{
var item = new VideoItem($@"C:\videos\{title}.mp4", title, 1, DateTimeOffset.UnixEpoch);
item.ApplyPerceptualHash(hash);
return item;
}
private LibraryService CreateService() => new(
_videos,
new InMemoryLabelRepository(),
Substitute.For<IVideoFileScanner>(),
Substitute.For<IMediaProbe>(),
Substitute.For<IThumbnailGenerator>(),
Substitute.For<IAnimatedPreviewGenerator>(),
Substitute.For<IVideoPerceptualHasher>(),
Options.Create(new LibraryOptions()),
NullLogger<LibraryService>.Instance);
}
+2
View File
@@ -75,6 +75,8 @@ public sealed class LabelTests
Substitute.For<IVideoFileScanner>(),
Substitute.For<IMediaProbe>(),
Substitute.For<IThumbnailGenerator>(),
Substitute.For<IAnimatedPreviewGenerator>(),
Substitute.For<IVideoPerceptualHasher>(),
Options.Create(new LibraryOptions()),
NullLogger<LibraryService>.Instance);
}
+278 -183
View File
@@ -1,183 +1,278 @@
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 LibraryServiceTests
{
private const string Root = @"C:\videos";
private readonly InMemoryVideoRepository _repository = new();
private readonly IVideoFileScanner _scanner = Substitute.For<IVideoFileScanner>();
private readonly IMediaProbe _probe = Substitute.For<IMediaProbe>();
private readonly IThumbnailGenerator _thumbnails = Substitute.For<IThumbnailGenerator>();
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");
// By default every remembered poster frame is still on disk.
_thumbnails.IsAvailable(Arg.Any<string?>()).Returns(true);
}
[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);
events.OfType<LibraryScanEvent.ItemUpdated>().Count().ShouldBe(2);
events.OfType<LibraryScanEvent.Completed>().Single().LibrarySize.ShouldBe(2);
}
[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");
_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>());
}
[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>());
}
[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);
}
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) => new(
_repository,
new InMemoryLabelRepository(),
_scanner,
_probe,
_thumbnails,
Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
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;
}
}
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 LibraryServiceTests
{
private const string Root = @"C:\videos";
private readonly InMemoryVideoRepository _repository = 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>();
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);
}
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) => new(
_repository,
new InMemoryLabelRepository(),
_scanner,
_probe,
_thumbnails,
_previews,
_hasher,
Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
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;
}
}
@@ -146,15 +146,19 @@ public sealed class AppSettingsStoreTests : IDisposable
{
DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}");
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
PreviewDirectory = Path.Combine(DataDirectory, "previews");
DatabaseFile = Path.Combine(DataDirectory, "library.db");
Directory.CreateDirectory(ThumbnailDirectory);
Directory.CreateDirectory(PreviewDirectory);
}
public string DataDirectory { get; }
public string ThumbnailDirectory { get; }
public string PreviewDirectory { get; }
public string DatabaseFile { get; }
public void Dispose() => Directory.Delete(DataDirectory, recursive: true);