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