Update README.md with project details, features, requirements, architecture, and data management for PLib video library manager.

This commit is contained in:
Leonid Pershin
2026-08-08 07:08:43 +03:00
parent e767e4a48c
commit ac05ea6b7f
59 changed files with 3010 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
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));
}
+1
View File
@@ -0,0 +1 @@
global using Xunit;
@@ -0,0 +1,50 @@
using PLib.Application.Abstractions;
using PLib.Application.Library;
using PLib.Domain.Videos;
namespace PLib.Tests.Library;
/// <summary>
/// A hand-written double rather than a mock: the scan logic is all about what ends up in the
/// repository, so the tests read better when they can just look at the resulting list.
/// </summary>
internal sealed class InMemoryVideoRepository : IVideoRepository
{
private readonly Dictionary<string, VideoItem> _items = new(LibraryPathComparer.Instance);
public int SaveCount { get; private set; }
public IReadOnlyCollection<VideoItem> Items => _items.Values;
public void Seed(params VideoItem[] items)
{
foreach (var item in items)
{
_items[item.FullPath] = item;
}
}
public Task<IReadOnlyList<VideoItem>> GetAllAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<VideoItem>>([.. _items.Values]);
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
Task.FromResult(_items.GetValueOrDefault(fullPath));
public Task AddAsync(VideoItem item, CancellationToken cancellationToken = default)
{
_items[item.FullPath] = item;
return Task.CompletedTask;
}
public Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default)
{
_items.Remove(item.FullPath);
return Task.CompletedTask;
}
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
SaveCount++;
return Task.CompletedTask;
}
}
@@ -0,0 +1,149 @@
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");
}
[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 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;
}
}
+22
View File
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>PLib.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<OutputType>Exe</OutputType>
<!-- xunit.v3 runs on Microsoft.Testing.Platform; no VSTest adapter needed. -->
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<PackageReference Include="Shouldly" />
<PackageReference Include="NSubstitute" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\PLib.Application\PLib.Application.csproj" />
<ProjectReference Include="..\..\src\PLib.Domain\PLib.Domain.csproj" />
</ItemGroup>
</Project>