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