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