Enhance video library management in PLib by introducing folder change tracking and improving video item metadata handling. Update IVideoRepository to include a method for loading video items with labels. Revise VideoPlayerViewModel to manage playback progress and integrate new UI elements for displaying watched status and resume options. Update MainWindowViewModel to observe folder changes for automatic rescanning. Enhance README.md to document these new features and usage instructions.

This commit is contained in:
Leonid Pershin
2026-08-09 07:06:57 +03:00
parent a938a48de9
commit 10c66baea8
37 changed files with 2572 additions and 956 deletions
+183 -182
View File
@@ -1,182 +1,183 @@
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,
_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>();
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;
}
}