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
@@ -0,0 +1,78 @@
using PLib.Domain.Videos;
using Shouldly;
namespace PLib.Tests.Domain;
public sealed class WatchProgressTests
{
private static VideoItem CreateHourLongVideo()
{
var item = new VideoItem(@"C:\videos\film.mp4", "film", 1_000, DateTimeOffset.UnixEpoch);
item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromHours(1), 1920, 1080, "h264"));
return item;
}
[Fact]
public void A_position_worth_returning_to_is_remembered()
{
var item = CreateHourLongVideo();
item.RememberProgress(TimeSpan.FromMinutes(20));
item.ResumePosition.ShouldBe(TimeSpan.FromMinutes(20));
item.WatchedFraction.ShouldBe(1.0 / 3, 0.01);
item.PlayCount.ShouldBe(0);
item.LastPlayedAt.ShouldNotBeNull();
}
[Fact]
public void Stopping_in_the_first_seconds_leaves_nothing_to_resume()
{
var item = CreateHourLongVideo();
item.RememberProgress(TimeSpan.FromSeconds(5));
item.ResumePosition.ShouldBeNull();
item.PlayCount.ShouldBe(0);
// It still counts as opened, which is what recently-played ordering uses.
item.LastPlayedAt.ShouldNotBeNull();
}
[Fact]
public void Reaching_the_credits_counts_as_watched_rather_than_as_a_resume_point()
{
var item = CreateHourLongVideo();
// Inside the end-of-playback slack: the viewer is done, not paused.
item.RememberProgress(TimeSpan.FromMinutes(60) - TimeSpan.FromSeconds(5));
item.ResumePosition.ShouldBeNull();
item.PlayCount.ShouldBe(1);
item.WatchedFraction.ShouldBe(0);
}
[Fact]
public void Watching_again_after_finishing_starts_a_fresh_resume_point()
{
var item = CreateHourLongVideo();
item.RememberProgress(TimeSpan.FromMinutes(60));
item.RememberProgress(TimeSpan.FromMinutes(3));
item.PlayCount.ShouldBe(1);
item.ResumePosition.ShouldBe(TimeSpan.FromMinutes(3));
}
[Fact]
public void A_video_of_unknown_length_still_remembers_where_it_stopped()
{
var item = new VideoItem(@"C:\videos\odd.mkv", "odd", 1_000, DateTimeOffset.UnixEpoch);
item.RememberProgress(TimeSpan.FromMinutes(5));
item.ResumePosition.ShouldBe(TimeSpan.FromMinutes(5));
// Without a duration there is no fraction to draw.
item.WatchedFraction.ShouldBe(0);
}
}
@@ -0,0 +1,36 @@
using PLib.Application.Abstractions;
using PLib.Domain.Videos;
namespace PLib.Tests.Library;
/// <summary>Hand-written double mirroring the EF repository's lookup-by-normalized-name rule.</summary>
internal sealed class InMemoryLabelRepository : ILabelRepository
{
private readonly List<LibraryLabel> _labels = [];
public Task<IReadOnlyList<LibraryLabel>> GetAllAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<LibraryLabel>>([.. _labels]);
public Task<LibraryLabel?> FindAsync(
LabelKind kind,
string name,
CancellationToken cancellationToken = default)
{
var normalized = LibraryLabel.Normalize(name);
return Task.FromResult(_labels.FirstOrDefault(
label => label.Kind == kind && label.NormalizedName == normalized));
}
public Task AddAsync(LibraryLabel label, CancellationToken cancellationToken = default)
{
_labels.Add(label);
return Task.CompletedTask;
}
public Task RemoveAsync(LibraryLabel label, CancellationToken cancellationToken = default)
{
_labels.Remove(label);
return Task.CompletedTask;
}
}
@@ -1,50 +1,54 @@
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;
}
}
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));
// Labels are held on the entity itself here, so there is nothing extra to load.
public Task<VideoItem?> FindWithLabelsAsync(Guid id, CancellationToken cancellationToken = default) =>
Task.FromResult(_items.Values.FirstOrDefault(item => item.Id == id));
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;
}
}
+80
View File
@@ -0,0 +1,80 @@
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 LabelTests
{
private readonly InMemoryVideoRepository _videos = new();
private readonly InMemoryLabelRepository _labels = new();
private readonly VideoItem _video = new(@"C:\videos\a.mp4", "a", 1_000, DateTimeOffset.UnixEpoch);
public LabelTests() => _videos.Seed(_video);
[Fact]
public async Task A_name_used_for_the_first_time_creates_the_label()
{
var label = await CreateService().AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
label.Name.ShouldBe("Комедия");
_video.Labels.ShouldHaveSingleItem();
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
}
[Fact]
public async Task The_same_name_in_another_case_reuses_the_label_that_already_exists()
{
var service = CreateService();
var first = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
var second = await service.AttachLabelAsync(_video.Id, " комедия ", LabelKind.Tag, Token);
second.Id.ShouldBe(first.Id);
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
// And attaching it twice must not double it up on the video.
_video.Labels.ShouldHaveSingleItem();
}
[Fact]
public async Task A_tag_and_a_collection_may_share_a_name()
{
var service = CreateService();
var tag = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Tag, Token);
var collection = await service.AttachLabelAsync(_video.Id, "Марвел", LabelKind.Collection, Token);
collection.Id.ShouldNotBe(tag.Id);
_video.Labels.Count.ShouldBe(2);
}
[Fact]
public async Task Detaching_leaves_the_label_itself_in_the_library()
{
var service = CreateService();
var label = await service.AttachLabelAsync(_video.Id, "Комедия", LabelKind.Tag, Token);
await service.DetachLabelAsync(_video.Id, label.Id, Token);
_video.Labels.ShouldBeEmpty();
// Other videos may still use it, and re-adding must not make a second one.
(await _labels.GetAllAsync(Token)).ShouldHaveSingleItem();
}
private static CancellationToken Token => TestContext.Current.CancellationToken;
private LibraryService CreateService() => new(
_videos,
_labels,
Substitute.For<IVideoFileScanner>(),
Substitute.For<IMediaProbe>(),
Substitute.For<IThumbnailGenerator>(),
Options.Create(new LibraryOptions()),
NullLogger<LibraryService>.Instance);
}
+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;
}
}