diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..cf127de
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,52 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = crlf
+indent_style = space
+indent_size = 4
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{xml,axaml,xaml,csproj,props,targets,json,yml,yaml}]
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.cs]
+# Namespaces
+csharp_style_namespace_declarations = file_scoped:warning
+
+# var
+csharp_style_var_when_type_is_apparent = true:suggestion
+csharp_style_var_elsewhere = true:suggestion
+
+# Modern language features
+csharp_style_expression_bodied_methods = when_on_single_line:suggestion
+csharp_style_expression_bodied_properties = true:suggestion
+csharp_style_prefer_primary_constructors = true:suggestion
+csharp_style_prefer_pattern_matching = true:suggestion
+csharp_style_prefer_switch_expression = true:suggestion
+csharp_prefer_braces = true:warning
+csharp_prefer_simple_using_statement = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_prefer_collection_expression = true:suggestion
+dotnet_style_readonly_field = true:warning
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
+
+# Usings
+dotnet_sort_system_directives_first = true
+csharp_using_directive_placement = outside_namespace:warning
+
+# Naming: private fields are _camelCase
+dotnet_naming_rule.private_fields_underscore.symbols = private_fields
+dotnet_naming_rule.private_fields_underscore.style = underscore_prefix
+dotnet_naming_rule.private_fields_underscore.severity = warning
+
+dotnet_naming_symbols.private_fields.applicable_kinds = field
+dotnet_naming_symbols.private_fields.applicable_accessibilities = private
+dotnet_naming_symbols.private_fields.required_modifiers =
+
+dotnet_naming_style.underscore_prefix.capitalization = camel_case
+dotnet_naming_style.underscore_prefix.required_prefix = _
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..0e210b8
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+ latest
+ enable
+ enable
+ true
+ true
+ false
+ true
+
+
+
+ PLib
+ PLib
+ 0.1.0
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..4ba586f
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,51 @@
+
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/PLib.slnx b/PLib.slnx
new file mode 100644
index 0000000..b6ee687
--- /dev/null
+++ b/PLib.slnx
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index b3a5ded..fd127f8 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,66 @@
# PLib
+Менеджер видеотеки на Avalonia: сканирует папки, вытаскивает превью через ffmpeg и
+показывает всё сеткой карточек.
+
+## Что уже работает
+
+- Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется.
+- Метаданные (длительность, разрешение, кодек) через ffprobe.
+- Постеры кадром из видео через ffmpeg, с кэшем на диске.
+- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
+- Светлая и тёмная темы.
+- Клик или Enter по карточке — открыть в системном плеере, правая кнопка — контекстное меню.
+
+## Требования
+
+- .NET 10 SDK
+- `ffmpeg` и `ffprobe` в `PATH`
+
+## Запуск
+
+```bash
+dotnet run --project src/PLib.Desktop
+```
+
+```bash
+dotnet test
+```
+
+## Архитектура
+
+Четыре слоя, зависимости направлены только внутрь:
+
+| Проект | Отвечает за | Знает о |
+| --- | --- | --- |
+| `PLib.Domain` | Сущность `VideoItem` и её инварианты | ни о чём |
+| `PLib.Application` | Сценарии (`LibraryService`) и абстракции портов | Domain |
+| `PLib.Infrastructure` | EF Core + SQLite, ffmpeg, файловая система | Application |
+| `PLib.Desktop` | Avalonia, ViewModel'и, composition root | Infrastructure |
+
+Ключевые решения:
+
+- **Сканирование — поток событий.** `ILibraryService.ScanAsync` возвращает
+ `IAsyncEnumerable`: карточки появляются по мере находок, а не после
+ завершения всего прохода. Тяжёлая часть (ffprobe + ffmpeg) идёт параллельно через
+ `Parallel.ForEachAsync`, результаты собираются в `Channel` и применяются к сущностям
+ по одному — трекер изменений EF не потокобезопасен.
+- **Вся работа вне UI-потока.** ViewModel оборачивает конвейер в `Task.Run` и возвращает
+ каждое событие в UI явно через `Dispatcher.UIThread`.
+- **Превью живут только пока видны.** `AsyncImage` запрашивает битмап при попадании в
+ визуальное дерево и отпускает при выходе; `ThumbnailCache` — LRU на 256 записей с
+ декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки.
+- **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт
+ `IServiceScopeFactory` и создаёт scope на каждый вызов.
+
+## Данные
+
+Всё пользовательское лежит в `%LOCALAPPDATA%\PLib`:
+
+- `library.db` — SQLite с метаданными;
+- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла);
+- `settings.json` — список папок, перечитывается на лету;
+- `logs/` — Serilog, ротация по дням.
+
+Схема создаётся через `EnsureCreated`. Когда форма таблицы устоится — заменить на
+миграции EF Core (`DatabaseInitializer` — единственное место, которое надо будет тронуть).
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..512142d
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "10.0.100",
+ "rollForward": "latestFeature"
+ }
+}
diff --git a/src/PLib.Application/Abstractions/IMediaProbe.cs b/src/PLib.Application/Abstractions/IMediaProbe.cs
new file mode 100644
index 0000000..13ab31b
--- /dev/null
+++ b/src/PLib.Application/Abstractions/IMediaProbe.cs
@@ -0,0 +1,14 @@
+using PLib.Domain.Videos;
+
+namespace PLib.Application.Abstractions;
+
+/// Reads duration, resolution and codec out of a media container.
+public interface IMediaProbe
+{
+ ///
+ /// Probes . Returns
+ /// when the file cannot be read or is not a media file — probing must never throw for
+ /// a single bad file, otherwise one corrupt video would abort a whole library scan.
+ ///
+ Task ProbeAsync(string fullPath, CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Application/Abstractions/IThumbnailGenerator.cs b/src/PLib.Application/Abstractions/IThumbnailGenerator.cs
new file mode 100644
index 0000000..c0c21c1
--- /dev/null
+++ b/src/PLib.Application/Abstractions/IThumbnailGenerator.cs
@@ -0,0 +1,15 @@
+namespace PLib.Application.Abstractions;
+
+/// Produces (and caches on disk) a poster frame for a video file.
+public interface IThumbnailGenerator
+{
+ ///
+ /// Returns the absolute path of the poster frame for ,
+ /// generating it if it is not cached yet. Returns null when no frame could be
+ /// extracted; a missing thumbnail is a normal outcome, not an error.
+ ///
+ Task GetOrCreateAsync(
+ string videoPath,
+ TimeSpan? duration,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Application/Abstractions/IVideoFileScanner.cs b/src/PLib.Application/Abstractions/IVideoFileScanner.cs
new file mode 100644
index 0000000..8516aff
--- /dev/null
+++ b/src/PLib.Application/Abstractions/IVideoFileScanner.cs
@@ -0,0 +1,13 @@
+namespace PLib.Application.Abstractions;
+
+/// A video file discovered on disk, before it is known to the library.
+/// Absolute path of the file.
+/// Size of the file on disk.
+/// Last write time of the file.
+public readonly record struct DiscoveredVideoFile(string FullPath, long SizeInBytes, DateTimeOffset ModifiedAt);
+
+/// Walks a folder tree and yields every file that looks like a video.
+public interface IVideoFileScanner
+{
+ IAsyncEnumerable ScanAsync(string rootFolder, CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Application/Abstractions/IVideoRepository.cs b/src/PLib.Application/Abstractions/IVideoRepository.cs
new file mode 100644
index 0000000..91e241b
--- /dev/null
+++ b/src/PLib.Application/Abstractions/IVideoRepository.cs
@@ -0,0 +1,21 @@
+using PLib.Domain.Videos;
+
+namespace PLib.Application.Abstractions;
+
+///
+/// Persistence boundary for the library. The application layer only ever talks to this
+/// interface, which keeps EF Core (and SQLite) an implementation detail of the outer ring.
+///
+public interface IVideoRepository
+{
+ Task> GetAllAsync(CancellationToken cancellationToken = default);
+
+ Task FindByPathAsync(string fullPath, CancellationToken cancellationToken = default);
+
+ Task AddAsync(VideoItem item, CancellationToken cancellationToken = default);
+
+ Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default);
+
+ /// Flushes every pending change made to tracked entities.
+ Task SaveChangesAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Application/Library/ILibraryService.cs b/src/PLib.Application/Library/ILibraryService.cs
new file mode 100644
index 0000000..6b3ed4c
--- /dev/null
+++ b/src/PLib.Application/Library/ILibraryService.cs
@@ -0,0 +1,18 @@
+using PLib.Domain.Videos;
+
+namespace PLib.Application.Library;
+
+/// Use cases the UI needs in order to show and refresh the video library.
+public interface ILibraryService
+{
+ /// Everything currently stored in the library, newest first.
+ Task> GetLibraryAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Reconciles the library with the configured folders and then fills in metadata and
+ /// poster frames for anything that is missing them, streaming progress as it goes.
+ ///
+ IAsyncEnumerable ScanAsync(
+ IReadOnlyList folders,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Application/Library/LibraryOptions.cs b/src/PLib.Application/Library/LibraryOptions.cs
new file mode 100644
index 0000000..825ff84
--- /dev/null
+++ b/src/PLib.Application/Library/LibraryOptions.cs
@@ -0,0 +1,37 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace PLib.Application.Library;
+
+/// User-tunable settings for how the library is discovered and indexed.
+public sealed class LibraryOptions
+{
+ public const string SectionName = "Library";
+
+ /// Root folders that make up the library.
+ public IList Folders { get; init; } = [];
+
+ /// File extensions treated as video, lower case and including the leading dot.
+ public IList VideoExtensions { get; init; } =
+ [
+ ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm", ".m4v", ".mpg", ".mpeg", ".flv", ".ts", ".m2ts",
+ ];
+
+ /// Width of generated poster frames in pixels; height follows the source aspect ratio.
+ [Range(160, 1920)]
+ public int ThumbnailWidth { get; init; } = 480;
+
+ ///
+ /// Fraction of the duration at which the poster frame is captured. Grabbing the very
+ /// first frame usually yields a black or logo frame, so we sample a bit into the file.
+ ///
+ [Range(0.0, 0.9)]
+ public double ThumbnailPositionRatio { get; init; } = 0.15;
+
+ /// How many files may be probed / rendered concurrently.
+ [Range(1, 32)]
+ public int MaxIndexingConcurrency { get; init; } = 4;
+
+ /// Files smaller than this are skipped as they are almost certainly not real videos.
+ [Range(0, long.MaxValue)]
+ public long MinimumFileSizeInBytes { get; init; } = 64 * 1024;
+}
diff --git a/src/PLib.Application/Library/LibraryPathComparer.cs b/src/PLib.Application/Library/LibraryPathComparer.cs
new file mode 100644
index 0000000..a81b1d9
--- /dev/null
+++ b/src/PLib.Application/Library/LibraryPathComparer.cs
@@ -0,0 +1,12 @@
+namespace PLib.Application.Library;
+
+///
+/// Compares file system paths the way the host platform does: case-insensitively on
+/// Windows, byte-for-byte everywhere else.
+///
+public static class LibraryPathComparer
+{
+ public static StringComparer Instance { get; } = OperatingSystem.IsWindows()
+ ? StringComparer.OrdinalIgnoreCase
+ : StringComparer.Ordinal;
+}
diff --git a/src/PLib.Application/Library/LibraryScanEvent.cs b/src/PLib.Application/Library/LibraryScanEvent.cs
new file mode 100644
index 0000000..acd43e3
--- /dev/null
+++ b/src/PLib.Application/Library/LibraryScanEvent.cs
@@ -0,0 +1,32 @@
+using PLib.Domain.Videos;
+
+namespace PLib.Application.Library;
+
+///
+/// Something that happened during a scan. The scan is exposed as a stream of these so the
+/// UI can render cards as soon as they are known instead of waiting for the whole pass.
+///
+public abstract record LibraryScanEvent
+{
+ private LibraryScanEvent()
+ {
+ }
+
+ /// The file system walk finished and we know how much work there is.
+ public sealed record DiscoveryCompleted(int FilesFound) : LibraryScanEvent;
+
+ /// A video is now part of the library (possibly still without a poster frame).
+ public sealed record ItemAdded(VideoItem Item) : LibraryScanEvent;
+
+ /// A video that is already displayed gained metadata or a poster frame.
+ public sealed record ItemUpdated(VideoItem Item) : LibraryScanEvent;
+
+ /// A video disappeared from disk and was dropped from the library.
+ public sealed record ItemRemoved(Guid Id) : LibraryScanEvent;
+
+ /// Indexing progress, reported after every processed file.
+ public sealed record IndexingProgress(int Processed, int Total) : LibraryScanEvent;
+
+ /// The scan finished successfully.
+ public sealed record Completed(int LibrarySize) : LibraryScanEvent;
+}
diff --git a/src/PLib.Application/Library/LibraryService.cs b/src/PLib.Application/Library/LibraryService.cs
new file mode 100644
index 0000000..7426a66
--- /dev/null
+++ b/src/PLib.Application/Library/LibraryService.cs
@@ -0,0 +1,184 @@
+using System.Runtime.CompilerServices;
+using System.Threading.Channels;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using PLib.Application.Abstractions;
+using PLib.Domain.Videos;
+
+namespace PLib.Application.Library;
+
+///
+public sealed class LibraryService(
+ IVideoRepository repository,
+ IVideoFileScanner scanner,
+ IMediaProbe mediaProbe,
+ IThumbnailGenerator thumbnailGenerator,
+ IOptions options,
+ ILogger logger) : ILibraryService
+{
+ /// How many indexed items to accumulate before flushing them to storage.
+ private const int SaveBatchSize = 25;
+
+ private readonly LibraryOptions _options = options.Value;
+
+ public async Task> GetLibraryAsync(CancellationToken cancellationToken = default)
+ {
+ var items = await repository.GetAllAsync(cancellationToken);
+ return [.. items.OrderByDescending(x => x.AddedAt)];
+ }
+
+ public async IAsyncEnumerable ScanAsync(
+ IReadOnlyList folders,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var known = (await repository.GetAllAsync(cancellationToken))
+ .ToDictionary(x => x.FullPath, LibraryPathComparer.Instance);
+
+ var discovered = await DiscoverAsync(folders, cancellationToken);
+ yield return new LibraryScanEvent.DiscoveryCompleted(discovered.Count);
+
+ var pending = new List();
+
+ foreach (var file in discovered.Values)
+ {
+ if (known.TryGetValue(file.FullPath, out var existing))
+ {
+ existing.RefreshFileFacts(file.SizeInBytes, file.ModifiedAt);
+ }
+ else
+ {
+ existing = new VideoItem(
+ file.FullPath,
+ Path.GetFileNameWithoutExtension(file.FullPath),
+ file.SizeInBytes,
+ file.ModifiedAt);
+
+ await repository.AddAsync(existing, cancellationToken);
+ known.Add(existing.FullPath, existing);
+ yield return new LibraryScanEvent.ItemAdded(existing);
+ }
+
+ if (!existing.IsIndexed)
+ {
+ pending.Add(existing);
+ }
+ }
+
+ foreach (var orphan in known.Values.Where(x => !discovered.ContainsKey(x.FullPath)).ToList())
+ {
+ await repository.RemoveAsync(orphan, cancellationToken);
+ known.Remove(orphan.FullPath);
+ yield return new LibraryScanEvent.ItemRemoved(orphan.Id);
+ }
+
+ await repository.SaveChangesAsync(cancellationToken);
+
+ await foreach (var indexed in IndexAsync(pending, cancellationToken))
+ {
+ yield return indexed;
+ }
+
+ await repository.SaveChangesAsync(cancellationToken);
+ yield return new LibraryScanEvent.Completed(known.Count);
+ }
+
+ private async Task> DiscoverAsync(
+ IReadOnlyList folders,
+ CancellationToken cancellationToken)
+ {
+ var discovered = new Dictionary(LibraryPathComparer.Instance);
+
+ foreach (var folder in folders)
+ {
+ await foreach (var file in scanner.ScanAsync(folder, cancellationToken))
+ {
+ if (file.SizeInBytes < _options.MinimumFileSizeInBytes)
+ {
+ continue;
+ }
+
+ // Overlapping roots are legal, so the first sighting of a path wins.
+ discovered.TryAdd(file.FullPath, file);
+ }
+ }
+
+ return discovered;
+ }
+
+ ///
+ /// Probes and renders poster frames with bounded concurrency. The expensive work runs in
+ /// parallel, but the results are applied to the entities one at a time by the consumer
+ /// because change tracking is not thread safe.
+ ///
+ private async IAsyncEnumerable IndexAsync(
+ IReadOnlyList pending,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ if (pending.Count == 0)
+ {
+ yield break;
+ }
+
+ var channel = Channel.CreateBounded(new BoundedChannelOptions(_options.MaxIndexingConcurrency * 4)
+ {
+ SingleReader = true,
+ });
+
+ var producer = Task.Run(
+ async () =>
+ {
+ try
+ {
+ var parallelOptions = new ParallelOptions
+ {
+ MaxDegreeOfParallelism = _options.MaxIndexingConcurrency,
+ CancellationToken = cancellationToken,
+ };
+
+ await Parallel.ForEachAsync(
+ pending,
+ parallelOptions,
+ async (item, token) =>
+ {
+ var info = await mediaProbe.ProbeAsync(item.FullPath, token);
+ var thumbnail = await thumbnailGenerator.GetOrCreateAsync(item.FullPath, info.Duration, token);
+ await channel.Writer.WriteAsync(new IndexResult(item, info, thumbnail), token);
+ });
+
+ channel.Writer.Complete();
+ }
+ catch (Exception ex)
+ {
+ channel.Writer.Complete(ex);
+ }
+ },
+ cancellationToken);
+
+ var processed = 0;
+
+ await foreach (var result in channel.Reader.ReadAllAsync(cancellationToken))
+ {
+ result.Item.ApplyTechnicalInfo(result.Info);
+
+ if (result.ThumbnailPath is not null)
+ {
+ result.Item.AttachThumbnail(result.ThumbnailPath);
+ }
+
+ processed++;
+
+ yield return new LibraryScanEvent.ItemUpdated(result.Item);
+ yield return new LibraryScanEvent.IndexingProgress(processed, pending.Count);
+
+ if (processed % SaveBatchSize == 0)
+ {
+ await repository.SaveChangesAsync(cancellationToken);
+ }
+ }
+
+ await producer;
+ logger.LogInformation("Indexed {Processed} of {Total} video files", processed, pending.Count);
+ }
+
+ private readonly record struct IndexResult(VideoItem Item, VideoTechnicalInfo Info, string? ThumbnailPath);
+}
diff --git a/src/PLib.Application/PLib.Application.csproj b/src/PLib.Application/PLib.Application.csproj
new file mode 100644
index 0000000..1867bb1
--- /dev/null
+++ b/src/PLib.Application/PLib.Application.csproj
@@ -0,0 +1,16 @@
+
+
+
+ PLib.Application
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PLib.Desktop/App.axaml b/src/PLib.Desktop/App.axaml
new file mode 100644
index 0000000..048969f
--- /dev/null
+++ b/src/PLib.Desktop/App.axaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PLib.Desktop/App.axaml.cs b/src/PLib.Desktop/App.axaml.cs
new file mode 100644
index 0000000..3d727f0
--- /dev/null
+++ b/src/PLib.Desktop/App.axaml.cs
@@ -0,0 +1,55 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Avalonia.Threading;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using PLib.Desktop.Controls;
+using PLib.Desktop.Imaging;
+using PLib.Desktop.ViewModels;
+using PLib.Desktop.Views;
+
+namespace PLib.Desktop;
+
+// Fully qualified: the PLib.Application namespace shadows the Application type in this assembly.
+public sealed class App : Avalonia.Application
+{
+ private IHost? _host;
+
+ public override void Initialize() => AvaloniaXamlLoader.Load(this);
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ _host = AppHost.Create(desktop.Args ?? []);
+ _host.Start();
+
+ AsyncImage.Loader = _host.Services.GetRequiredService();
+
+ var viewModel = _host.Services.GetRequiredService();
+ desktop.MainWindow = new MainWindow { DataContext = viewModel };
+ desktop.Exit += OnExit;
+
+ // Kick the first load off once the dispatcher is running, so a failure surfaces
+ // through the view model instead of disappearing into an unobserved task.
+ Dispatcher.UIThread.Post(
+ () => viewModel.InitializeCommand.Execute(null),
+ DispatcherPriority.Background);
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+
+ private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e)
+ {
+ if (_host is null)
+ {
+ return;
+ }
+
+ _host.StopAsync(TimeSpan.FromSeconds(3)).GetAwaiter().GetResult();
+ _host.Dispose();
+ _host = null;
+ }
+}
diff --git a/src/PLib.Desktop/AppHost.cs b/src/PLib.Desktop/AppHost.cs
new file mode 100644
index 0000000..b871cf7
--- /dev/null
+++ b/src/PLib.Desktop/AppHost.cs
@@ -0,0 +1,69 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using PLib.Desktop.Imaging;
+using PLib.Desktop.Services;
+using PLib.Desktop.ViewModels;
+using PLib.Infrastructure;
+using PLib.Infrastructure.Storage;
+using Serilog;
+
+namespace PLib.Desktop;
+
+///
+/// Composition root. Everything the application is made of is wired up here and nowhere else.
+///
+internal static class AppHost
+{
+ public static IHost Create(string[] args)
+ {
+ // The paths are needed to locate the user settings file, which is itself a
+ // configuration source — so they are built before the container exists and then
+ // handed to it as an instance.
+ var paths = new AppPaths();
+
+ // A desktop app is launched from arbitrary working directories, so the content root
+ // has to be the folder the executable lives in rather than Environment.CurrentDirectory.
+ var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
+ {
+ Args = args,
+ ContentRootPath = AppContext.BaseDirectory,
+ });
+
+ builder.Configuration.AddJsonFile(
+ Path.Combine(paths.DataDirectory, "settings.json"),
+ optional: true,
+ reloadOnChange: true);
+
+ ConfigureLogging(builder, paths);
+
+ builder.Services.AddSingleton(paths);
+ builder.Services.AddPLibInfrastructure(builder.Configuration);
+
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton(sp => sp.GetRequiredService());
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+
+ return builder.Build();
+ }
+
+ private static void ConfigureLogging(HostApplicationBuilder builder, IAppPaths paths)
+ {
+ Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.Information()
+ .WriteTo.Console()
+ .WriteTo.File(
+ Path.Combine(paths.DataDirectory, "logs", "plib-.log"),
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: 7)
+ .CreateLogger();
+
+ builder.Logging.ClearProviders();
+ builder.Logging.AddSerilog(Log.Logger, dispose: true);
+ }
+}
diff --git a/src/PLib.Desktop/Controls/AsyncImage.cs b/src/PLib.Desktop/Controls/AsyncImage.cs
new file mode 100644
index 0000000..9879365
--- /dev/null
+++ b/src/PLib.Desktop/Controls/AsyncImage.cs
@@ -0,0 +1,201 @@
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using PLib.Desktop.Imaging;
+
+namespace PLib.Desktop.Controls;
+
+///
+/// Draws a poster frame that is loaded only while the control is actually on screen.
+///
+///
+/// A virtualised grid recycles containers as the user scrolls, so binding a decoded
+/// straight into the view model would keep every frame the user has ever
+/// passed alive. Instead the control asks the shared loader when it is attached to the visual
+/// tree and drops its reference when it is detached, which keeps memory proportional to what
+/// is visible rather than to the size of the library.
+///
+public sealed class AsyncImage : Control
+{
+ public static readonly StyledProperty SourceProperty =
+ AvaloniaProperty.Register(nameof(Source));
+
+ public static readonly StyledProperty DecodeWidthProperty =
+ AvaloniaProperty.Register(nameof(DecodeWidth), defaultValue: 400);
+
+ public static readonly StyledProperty PlaceholderBrushProperty =
+ AvaloniaProperty.Register(nameof(PlaceholderBrush));
+
+ ///
+ /// Shared loader, assigned once by the composition root. A control is created by the XAML
+ /// runtime and therefore cannot take constructor dependencies.
+ ///
+ public static IThumbnailLoader? Loader { get; set; }
+
+ private CancellationTokenSource? _pending;
+ private Bitmap? _bitmap;
+ private bool _isAttached;
+
+ static AsyncImage()
+ {
+ AffectsRender(SourceProperty, PlaceholderBrushProperty);
+ }
+
+ public AsyncImage()
+ {
+ Opacity = 0;
+ Transitions =
+ [
+ new DoubleTransition
+ {
+ Property = OpacityProperty,
+ Duration = TimeSpan.FromMilliseconds(220),
+ Easing = new CubicEaseOut(),
+ },
+ ];
+ }
+
+ public string? Source
+ {
+ get => GetValue(SourceProperty);
+ set => SetValue(SourceProperty, value);
+ }
+
+ /// Target width in pixels for decoding; smaller means less memory per card.
+ public int DecodeWidth
+ {
+ get => GetValue(DecodeWidthProperty);
+ set => SetValue(DecodeWidthProperty, value);
+ }
+
+ public IBrush? PlaceholderBrush
+ {
+ get => GetValue(PlaceholderBrushProperty);
+ set => SetValue(PlaceholderBrushProperty, value);
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ _isAttached = true;
+ BeginLoad();
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ _isAttached = false;
+ Release();
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == SourceProperty || change.Property == DecodeWidthProperty)
+ {
+ Release();
+ BeginLoad();
+ }
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ var bounds = new Rect(Bounds.Size);
+
+ if (bounds.Width <= 0 || bounds.Height <= 0)
+ {
+ return;
+ }
+
+ if (_bitmap is null)
+ {
+ if (PlaceholderBrush is { } placeholder)
+ {
+ context.FillRectangle(placeholder, bounds);
+ }
+
+ return;
+ }
+
+ context.DrawImage(_bitmap, CoverSourceRect(_bitmap.PixelSize, bounds), bounds);
+ }
+
+ ///
+ /// Picks the largest centred crop of the source that has the same aspect ratio as the
+ /// destination, i.e. CSS object-fit: cover: fill the card, never letterbox.
+ ///
+ private static Rect CoverSourceRect(PixelSize source, Rect destination)
+ {
+ var scale = Math.Max(destination.Width / source.Width, destination.Height / source.Height);
+ var width = destination.Width / scale;
+ var height = destination.Height / scale;
+
+ return new Rect((source.Width - width) / 2, (source.Height - height) / 2, width, height);
+ }
+
+ private void BeginLoad()
+ {
+ if (!_isAttached || Loader is not { } loader || string.IsNullOrEmpty(Source))
+ {
+ return;
+ }
+
+ var cts = new CancellationTokenSource();
+ _pending = cts;
+
+ _ = LoadAsync(loader, Source, DecodeWidth, cts);
+ }
+
+ private async Task LoadAsync(IThumbnailLoader loader, string path, int decodeWidth, CancellationTokenSource cts)
+ {
+ try
+ {
+ var bitmap = await loader.GetAsync(path, decodeWidth, cts.Token);
+
+ if (bitmap is null || cts.IsCancellationRequested)
+ {
+ return;
+ }
+
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ // The container may have been recycled onto a different item while we decoded.
+ if (cts.IsCancellationRequested || !ReferenceEquals(_pending, cts))
+ {
+ return;
+ }
+
+ _bitmap = bitmap;
+ Opacity = 1;
+ InvalidateVisual();
+ });
+ }
+ catch (OperationCanceledException)
+ {
+ // Scrolled away before the frame was ready; nothing to show and nothing to report.
+ }
+ }
+
+ ///
+ /// Cancels any in-flight decode and drops the reference to the bitmap. The bitmap itself
+ /// belongs to the cache and is never disposed here.
+ ///
+ private void Release()
+ {
+ if (_pending is { } cts)
+ {
+ _pending = null;
+ cts.Cancel();
+ cts.Dispose();
+ }
+
+ _bitmap = null;
+ Opacity = 0;
+ InvalidateVisual();
+ }
+}
diff --git a/src/PLib.Desktop/Imaging/IThumbnailLoader.cs b/src/PLib.Desktop/Imaging/IThumbnailLoader.cs
new file mode 100644
index 0000000..de2cacb
--- /dev/null
+++ b/src/PLib.Desktop/Imaging/IThumbnailLoader.cs
@@ -0,0 +1,9 @@
+using Avalonia.Media.Imaging;
+
+namespace PLib.Desktop.Imaging;
+
+/// The view-side contract for turning a poster frame path into a drawable bitmap.
+public interface IThumbnailLoader
+{
+ Task GetAsync(string path, int decodeWidth, CancellationToken cancellationToken);
+}
diff --git a/src/PLib.Desktop/Imaging/ThumbnailCache.cs b/src/PLib.Desktop/Imaging/ThumbnailCache.cs
new file mode 100644
index 0000000..3a550c9
--- /dev/null
+++ b/src/PLib.Desktop/Imaging/ThumbnailCache.cs
@@ -0,0 +1,114 @@
+using Avalonia.Media.Imaging;
+using Microsoft.Extensions.Logging;
+
+namespace PLib.Desktop.Imaging;
+
+///
+/// Decodes poster frames off the UI thread and keeps the most recently used ones in memory.
+///
+///
+/// A library can hold thousands of files, so decoded bitmaps cannot all stay alive. Entries
+/// are evicted by least-recent use but never disposed: a card that is still on screen may be
+/// rendering the very bitmap we drop, and the garbage collector is the only party that knows
+/// when the last reference is gone.
+///
+public sealed class ThumbnailCache(ILogger logger) : IThumbnailLoader
+{
+ private const int Capacity = 256;
+
+ /// Decoding is CPU bound; a couple of workers keeps scrolling smooth without thrashing.
+ private readonly SemaphoreSlim _decodeSlots = new(Math.Max(2, Environment.ProcessorCount / 2));
+
+ private readonly Lock _gate = new();
+ private readonly Dictionary> _entries = [];
+ private readonly LinkedList _recency = [];
+
+ public async Task GetAsync(string path, int decodeWidth, CancellationToken cancellationToken)
+ {
+ var key = new CacheKey(path, decodeWidth);
+
+ if (TryTouch(key, out var cached))
+ {
+ return cached;
+ }
+
+ await _decodeSlots.WaitAsync(cancellationToken);
+
+ try
+ {
+ // Another card may have decoded the same frame while we waited for a slot.
+ if (TryTouch(key, out cached))
+ {
+ return cached;
+ }
+
+ var bitmap = await Task.Run(() => Decode(path, decodeWidth), cancellationToken);
+
+ if (bitmap is not null)
+ {
+ Store(key, bitmap);
+ }
+
+ return bitmap;
+ }
+ finally
+ {
+ _decodeSlots.Release();
+ }
+ }
+
+ private Bitmap? Decode(string path, int decodeWidth)
+ {
+ try
+ {
+ using var stream = File.OpenRead(path);
+ return Bitmap.DecodeToWidth(stream, decodeWidth, BitmapInterpolationMode.HighQuality);
+ }
+ catch (Exception ex)
+ {
+ logger.LogDebug(ex, "Could not decode thumbnail {Path}", path);
+ return null;
+ }
+ }
+
+ private bool TryTouch(CacheKey key, out Bitmap? bitmap)
+ {
+ lock (_gate)
+ {
+ if (_entries.TryGetValue(key, out var node))
+ {
+ _recency.Remove(node);
+ _recency.AddFirst(node);
+ bitmap = node.Value.Bitmap;
+ return true;
+ }
+ }
+
+ bitmap = null;
+ return false;
+ }
+
+ private void Store(CacheKey key, Bitmap bitmap)
+ {
+ lock (_gate)
+ {
+ if (_entries.ContainsKey(key))
+ {
+ return;
+ }
+
+ _entries[key] = _recency.AddFirst(new CacheEntry(key, bitmap));
+
+ while (_recency.Count > Capacity)
+ {
+ var evicted = _recency.Last!;
+ _recency.RemoveLast();
+ _entries.Remove(evicted.Value.Key);
+ }
+ }
+ }
+
+ private readonly record struct CacheKey(string Path, int DecodeWidth);
+
+ private readonly record struct CacheEntry(CacheKey Key, Bitmap Bitmap);
+}
diff --git a/src/PLib.Desktop/PLib.Desktop.csproj b/src/PLib.Desktop/PLib.Desktop.csproj
new file mode 100644
index 0000000..38c0cff
--- /dev/null
+++ b/src/PLib.Desktop/PLib.Desktop.csproj
@@ -0,0 +1,38 @@
+
+
+
+ WinExe
+ PLib.Desktop
+ PLib
+ true
+ app.manifest
+ true
+
+
+
+
+
+ PreserveNewest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PLib.Desktop/Program.cs b/src/PLib.Desktop/Program.cs
new file mode 100644
index 0000000..ca1ee88
--- /dev/null
+++ b/src/PLib.Desktop/Program.cs
@@ -0,0 +1,18 @@
+using Avalonia;
+
+namespace PLib.Desktop;
+
+internal static class Program
+{
+ // Avalonia must be initialised before anything touches its types, so keep Main free of
+ // any other work and let App own the application host.
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ /// Also used by the XAML previewer, which requires this exact signature.
+ public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/src/PLib.Desktop/Services/IFolderPicker.cs b/src/PLib.Desktop/Services/IFolderPicker.cs
new file mode 100644
index 0000000..c4399fc
--- /dev/null
+++ b/src/PLib.Desktop/Services/IFolderPicker.cs
@@ -0,0 +1,7 @@
+namespace PLib.Desktop.Services;
+
+/// Asks the user for a folder. Returns null when the dialog is dismissed.
+public interface IFolderPicker
+{
+ Task PickFolderAsync(string title, CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Desktop/Services/ILibrarySettingsStore.cs b/src/PLib.Desktop/Services/ILibrarySettingsStore.cs
new file mode 100644
index 0000000..fb82dc4
--- /dev/null
+++ b/src/PLib.Desktop/Services/ILibrarySettingsStore.cs
@@ -0,0 +1,11 @@
+namespace PLib.Desktop.Services;
+
+///
+/// Persists the parts of the user can change
+/// at runtime. Writes land in a JSON file that is also a configuration source, so
+/// IOptionsMonitor picks the change up without a restart.
+///
+public interface ILibrarySettingsStore
+{
+ Task SaveFoldersAsync(IReadOnlyList folders, CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Desktop/Services/JsonLibrarySettingsStore.cs b/src/PLib.Desktop/Services/JsonLibrarySettingsStore.cs
new file mode 100644
index 0000000..a3c5dad
--- /dev/null
+++ b/src/PLib.Desktop/Services/JsonLibrarySettingsStore.cs
@@ -0,0 +1,64 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using PLib.Application.Library;
+using PLib.Infrastructure.Storage;
+
+namespace PLib.Desktop.Services;
+
+///
+public sealed class JsonLibrarySettingsStore(IAppPaths paths) : ILibrarySettingsStore
+{
+ private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
+
+ private readonly SemaphoreSlim _writeLock = new(1, 1);
+
+ private string SettingsFile => Path.Combine(paths.DataDirectory, "settings.json");
+
+ public async Task SaveFoldersAsync(
+ IReadOnlyList folders,
+ CancellationToken cancellationToken = default)
+ {
+ await _writeLock.WaitAsync(cancellationToken);
+
+ try
+ {
+ var root = await ReadRootAsync(cancellationToken);
+
+ if (root[LibraryOptions.SectionName] is not JsonObject section)
+ {
+ section = [];
+ root[LibraryOptions.SectionName] = section;
+ }
+
+ section["Folders"] = new JsonArray([.. folders.Select(f => (JsonNode)JsonValue.Create(f))]);
+
+ // Write through a temp file so an interrupted save cannot corrupt the settings.
+ var staging = SettingsFile + ".tmp";
+ await File.WriteAllTextAsync(staging, root.ToJsonString(WriteOptions), cancellationToken);
+ File.Move(staging, SettingsFile, overwrite: true);
+ }
+ finally
+ {
+ _writeLock.Release();
+ }
+ }
+
+ private async Task ReadRootAsync(CancellationToken cancellationToken)
+ {
+ if (!File.Exists(SettingsFile))
+ {
+ return [];
+ }
+
+ try
+ {
+ var json = await File.ReadAllTextAsync(SettingsFile, cancellationToken);
+ return JsonNode.Parse(json) as JsonObject ?? [];
+ }
+ catch (JsonException)
+ {
+ // A hand-edited, broken settings file should not stop the app from saving.
+ return [];
+ }
+ }
+}
diff --git a/src/PLib.Desktop/Services/StorageProviderFolderPicker.cs b/src/PLib.Desktop/Services/StorageProviderFolderPicker.cs
new file mode 100644
index 0000000..928e0c7
--- /dev/null
+++ b/src/PLib.Desktop/Services/StorageProviderFolderPicker.cs
@@ -0,0 +1,28 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Platform.Storage;
+
+namespace PLib.Desktop.Services;
+
+///
+public sealed class StorageProviderFolderPicker : IFolderPicker
+{
+ public async Task PickFolderAsync(string title, CancellationToken cancellationToken = default)
+ {
+ if (Avalonia.Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
+ {
+ MainWindow.StorageProvider: { } storageProvider,
+ })
+ {
+ return null;
+ }
+
+ var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ Title = title,
+ AllowMultiple = false,
+ });
+
+ return folders.Count > 0 ? folders[0].TryGetLocalPath() : null;
+ }
+}
diff --git a/src/PLib.Desktop/Services/SystemShell.cs b/src/PLib.Desktop/Services/SystemShell.cs
new file mode 100644
index 0000000..8a4ee2d
--- /dev/null
+++ b/src/PLib.Desktop/Services/SystemShell.cs
@@ -0,0 +1,58 @@
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+
+namespace PLib.Desktop.Services;
+
+/// Hands a file over to whatever the operating system uses to open or show it.
+public interface ISystemShell
+{
+ void OpenFile(string path);
+
+ void RevealInFileManager(string path);
+}
+
+///
+public sealed class SystemShell(ILogger logger) : ISystemShell
+{
+ public void OpenFile(string path) => Start(new ProcessStartInfo(path) { UseShellExecute = true }, path);
+
+ public void RevealInFileManager(string path)
+ {
+ ProcessStartInfo startInfo;
+
+ if (OperatingSystem.IsWindows())
+ {
+ startInfo = new ProcessStartInfo("explorer.exe", $"/select,\"{path}\"");
+ }
+ else if (OperatingSystem.IsMacOS())
+ {
+ startInfo = new ProcessStartInfo("open", ["-R", path]);
+ }
+ else
+ {
+ var folder = Path.GetDirectoryName(path);
+
+ if (folder is null)
+ {
+ return;
+ }
+
+ startInfo = new ProcessStartInfo("xdg-open", [folder]);
+ }
+
+ Start(startInfo, path);
+ }
+
+ private void Start(ProcessStartInfo startInfo, string path)
+ {
+ try
+ {
+ using var process = Process.Start(startInfo);
+ }
+ catch (Exception ex)
+ {
+ // Nothing actionable for the user here; a missing handler is not a crash.
+ logger.LogWarning(ex, "Could not hand {Path} to the shell", path);
+ }
+ }
+}
diff --git a/src/PLib.Desktop/Services/ThemeService.cs b/src/PLib.Desktop/Services/ThemeService.cs
new file mode 100644
index 0000000..a66945d
--- /dev/null
+++ b/src/PLib.Desktop/Services/ThemeService.cs
@@ -0,0 +1,28 @@
+using Avalonia;
+using Avalonia.Styling;
+
+namespace PLib.Desktop.Services;
+
+/// Switches the application between the light and dark variants.
+public interface IThemeService
+{
+ void Toggle();
+}
+
+///
+public sealed class ThemeService : IThemeService
+{
+ public void Toggle()
+ {
+ if (Avalonia.Application.Current is not { } application)
+ {
+ return;
+ }
+
+ // ActualThemeVariant resolves "follow the system" to whatever is on screen right now,
+ // which is what the user is actually toggling away from.
+ application.RequestedThemeVariant = application.ActualThemeVariant == ThemeVariant.Dark
+ ? ThemeVariant.Light
+ : ThemeVariant.Dark;
+ }
+}
diff --git a/src/PLib.Desktop/Themes/LibraryStyles.axaml b/src/PLib.Desktop/Themes/LibraryStyles.axaml
new file mode 100644
index 0000000..9b111a4
--- /dev/null
+++ b/src/PLib.Desktop/Themes/LibraryStyles.axaml
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PLib.Desktop/Themes/Palette.axaml b/src/PLib.Desktop/Themes/Palette.axaml
new file mode 100644
index 0000000..5e0629c
--- /dev/null
+++ b/src/PLib.Desktop/Themes/Palette.axaml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PLib.Desktop/ViewModels/DisplayText.cs b/src/PLib.Desktop/ViewModels/DisplayText.cs
new file mode 100644
index 0000000..0e007e9
--- /dev/null
+++ b/src/PLib.Desktop/ViewModels/DisplayText.cs
@@ -0,0 +1,47 @@
+using System.Globalization;
+
+namespace PLib.Desktop.ViewModels;
+
+/// Turns raw numbers into the short strings the cards show.
+internal static class DisplayText
+{
+ private static readonly string[] SizeUnits = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
+
+ public static string Duration(TimeSpan? duration) => duration switch
+ {
+ null or { TotalSeconds: < 1 } => "—",
+ { TotalHours: >= 1 } value => value.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture),
+ var value => value.Value.ToString(@"m\:ss", CultureInfo.InvariantCulture),
+ };
+
+ public static string FileSize(long bytes)
+ {
+ double value = bytes;
+ var unit = 0;
+
+ while (value >= 1024 && unit < SizeUnits.Length - 1)
+ {
+ value /= 1024;
+ unit++;
+ }
+
+ var precision = value < 10 && unit > 0 ? 1 : 0;
+ return string.Create(CultureInfo.CurrentCulture, $"{Math.Round(value, precision)} {SizeUnits[unit]}");
+ }
+
+ ///
+ /// Names the vertical resolution the way people talk about it, falling back to the raw
+ /// dimensions for anything that does not match a familiar tier.
+ ///
+ public static string? Quality(int? width, int? height) => (width, height) switch
+ {
+ (null, _) or (_, null) => null,
+ ( >= 7000, _) => "8K",
+ ( >= 3500, _) => "4K",
+ (_, >= 2000) => "1440p",
+ (_, >= 1000) => "1080p",
+ (_, >= 700) => "720p",
+ (_, >= 460) => "480p",
+ var (w, h) => $"{w}×{h}",
+ };
+}
diff --git a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..ad3a6c8
--- /dev/null
+++ b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,302 @@
+using System.Collections.ObjectModel;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using PLib.Application.Library;
+using PLib.Desktop.Services;
+
+namespace PLib.Desktop.ViewModels;
+
+public sealed partial class MainWindowViewModel : ObservableObject
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly IOptionsMonitor _options;
+ private readonly ILibrarySettingsStore _settingsStore;
+ private readonly IFolderPicker _folderPicker;
+ private readonly ISystemShell _shell;
+ private readonly IThemeService _theme;
+ private readonly ILogger _logger;
+
+ /// Every card we know about; is the filtered, sorted view of it.
+ private readonly List _all = [];
+ private readonly Dictionary _byId = [];
+
+ public MainWindowViewModel(
+ IServiceScopeFactory scopeFactory,
+ IOptionsMonitor options,
+ ILibrarySettingsStore settingsStore,
+ IFolderPicker folderPicker,
+ ISystemShell shell,
+ IThemeService theme,
+ ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _options = options;
+ _settingsStore = settingsStore;
+ _folderPicker = folderPicker;
+ _shell = shell;
+ _theme = theme;
+ _logger = logger;
+
+ SelectedSort = SortOption.All[0];
+ }
+
+ public ObservableCollection Videos { get; } = [];
+
+ public IReadOnlyList SortOptions => SortOption.All;
+
+ public IReadOnlyList Folders => [.. _options.CurrentValue.Folders];
+
+ [ObservableProperty]
+ public partial string SearchText { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ public partial SortOption SelectedSort { get; set; }
+
+ [ObservableProperty]
+ public partial bool IsScanning { get; set; }
+
+ [ObservableProperty]
+ public partial string StatusText { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ public partial double ScanProgress { get; set; }
+
+ [ObservableProperty]
+ public partial bool IsProgressIndeterminate { get; set; }
+
+ /// True when the library is empty and there is nothing to show but the call to action.
+ public bool IsEmpty => Videos.Count == 0 && !IsScanning;
+
+ public bool HasFolders => _options.CurrentValue.Folders.Count > 0;
+
+ partial void OnSearchTextChanged(string value) => RebuildView();
+
+ partial void OnSelectedSortChanged(SortOption value) => RebuildView();
+
+ partial void OnIsScanningChanged(bool value) => OnPropertyChanged(nameof(IsEmpty));
+
+ /// Loads whatever is already in the database, then refreshes it against disk.
+ [RelayCommand]
+ private async Task InitializeAsync()
+ {
+ try
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var library = scope.ServiceProvider.GetRequiredService();
+
+ foreach (var item in await library.GetLibraryAsync())
+ {
+ var card = new VideoCardViewModel(item, _shell);
+ _all.Add(card);
+ _byId[card.Id] = card;
+ }
+ }
+ catch (Exception ex)
+ {
+ // An unobserved failure here would tear the process down through the command's
+ // task; the user gets a message instead and can still pick a folder.
+ _logger.LogError(ex, "Could not load the stored library");
+ StatusText = "Не удалось открыть базу библиотеки — подробности в журнале";
+ return;
+ }
+
+ RebuildView();
+
+ if (HasFolders)
+ {
+ await ScanCommand.ExecuteAsync(null);
+ }
+ else
+ {
+ StatusText = "Библиотека пуста — добавьте папку с видео";
+ }
+ }
+
+ [RelayCommand(IncludeCancelCommand = true)]
+ private async Task ScanAsync(CancellationToken cancellationToken)
+ {
+ var folders = _options.CurrentValue.Folders.ToArray();
+
+ if (folders.Length == 0)
+ {
+ StatusText = "Не выбрано ни одной папки";
+ return;
+ }
+
+ IsScanning = true;
+ IsProgressIndeterminate = true;
+ ScanProgress = 0;
+ StatusText = "Поиск файлов…";
+
+ try
+ {
+ // Task.Run detaches the whole pipeline from the UI synchronisation context, so
+ // scanning, probing and database work never touch the render thread. Every event
+ // is marshalled back explicitly.
+ await Task.Run(
+ async () =>
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var library = scope.ServiceProvider.GetRequiredService();
+
+ await foreach (var scanEvent in library.ScanAsync(folders, cancellationToken))
+ {
+ await Dispatcher.UIThread.InvokeAsync(() => Handle(scanEvent));
+ }
+ },
+ cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ StatusText = "Сканирование отменено";
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Library scan failed");
+ StatusText = "Не удалось просканировать библиотеку — подробности в журнале";
+ }
+ finally
+ {
+ IsScanning = false;
+ IsProgressIndeterminate = false;
+ RebuildView();
+ }
+ }
+
+ [RelayCommand]
+ private async Task AddFolderAsync()
+ {
+ var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео");
+
+ if (folder is null)
+ {
+ return;
+ }
+
+ var folders = _options.CurrentValue.Folders.ToList();
+
+ if (folders.Contains(folder, LibraryPathComparer.Instance))
+ {
+ return;
+ }
+
+ folders.Add(folder);
+ await _settingsStore.SaveFoldersAsync(folders);
+
+ // IOptionsMonitor reloads from the file asynchronously; wait for it so the scan below
+ // sees the folder we just added instead of racing the file watcher.
+ await WaitForFolderAsync(folder);
+
+ OnPropertyChanged(nameof(Folders));
+ OnPropertyChanged(nameof(HasFolders));
+
+ await ScanCommand.ExecuteAsync(null);
+ }
+
+ [RelayCommand]
+ private void ToggleTheme() => _theme.Toggle();
+
+ private async Task WaitForFolderAsync(string folder)
+ {
+ for (var attempt = 0; attempt < 20; attempt++)
+ {
+ if (_options.CurrentValue.Folders.Contains(folder, LibraryPathComparer.Instance))
+ {
+ return;
+ }
+
+ await Task.Delay(50);
+ }
+
+ _logger.LogWarning("Configuration did not pick up the new folder {Folder} in time", folder);
+ }
+
+ private void Handle(LibraryScanEvent scanEvent)
+ {
+ switch (scanEvent)
+ {
+ case LibraryScanEvent.DiscoveryCompleted discovery:
+ StatusText = $"Найдено файлов: {discovery.FilesFound}";
+ break;
+
+ case LibraryScanEvent.ItemAdded added:
+ {
+ var card = new VideoCardViewModel(added.Item, _shell);
+ _all.Add(card);
+ _byId[card.Id] = card;
+ InsertIntoView(card);
+ break;
+ }
+
+ case LibraryScanEvent.ItemUpdated updated
+ when _byId.TryGetValue(updated.Item.Id, out var existing):
+ existing.Apply(updated.Item);
+ break;
+
+ case LibraryScanEvent.ItemRemoved removed
+ when _byId.Remove(removed.Id, out var dropped):
+ _all.Remove(dropped);
+ Videos.Remove(dropped);
+ OnPropertyChanged(nameof(IsEmpty));
+ break;
+
+ case LibraryScanEvent.IndexingProgress progress:
+ IsProgressIndeterminate = false;
+ ScanProgress = progress.Total == 0 ? 100 : progress.Processed * 100.0 / progress.Total;
+ StatusText = $"Обработка превью: {progress.Processed} из {progress.Total}";
+ break;
+
+ case LibraryScanEvent.Completed completed:
+ ScanProgress = 100;
+ StatusText = completed.LibrarySize == 0
+ ? "В выбранных папках не нашлось видео"
+ : $"В библиотеке {completed.LibrarySize} видео";
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ private void InsertIntoView(VideoCardViewModel card)
+ {
+ if (!PassesFilter(card))
+ {
+ return;
+ }
+
+ // Appending keeps the grid stable while a scan streams in; the final RebuildView
+ // puts everything in the requested order once the scan settles.
+ Videos.Add(card);
+ OnPropertyChanged(nameof(IsEmpty));
+ }
+
+ private bool PassesFilter(VideoCardViewModel card) =>
+ string.IsNullOrWhiteSpace(SearchText) || card.Matches(SearchText.Trim());
+
+ private void RebuildView()
+ {
+ var visible = _all.Where(PassesFilter);
+
+ visible = SelectedSort.Sort switch
+ {
+ LibrarySort.TitleAscending => visible.OrderBy(x => x.Title, StringComparer.CurrentCultureIgnoreCase),
+ LibrarySort.LongestFirst => visible.OrderByDescending(x => x.RawDuration ?? TimeSpan.Zero),
+ LibrarySort.LargestFirst => visible.OrderByDescending(x => x.RawSizeInBytes),
+ _ => visible.OrderByDescending(x => x.AddedAt),
+ };
+
+ Videos.Clear();
+
+ foreach (var card in visible)
+ {
+ Videos.Add(card);
+ }
+
+ OnPropertyChanged(nameof(IsEmpty));
+ }
+}
diff --git a/src/PLib.Desktop/ViewModels/SortOption.cs b/src/PLib.Desktop/ViewModels/SortOption.cs
new file mode 100644
index 0000000..16d86e2
--- /dev/null
+++ b/src/PLib.Desktop/ViewModels/SortOption.cs
@@ -0,0 +1,21 @@
+namespace PLib.Desktop.ViewModels;
+
+public enum LibrarySort
+{
+ RecentlyAdded,
+ TitleAscending,
+ LongestFirst,
+ LargestFirst,
+}
+
+/// A sort order together with the label the combo box shows for it.
+public sealed record SortOption(LibrarySort Sort, string Label)
+{
+ public static IReadOnlyList All { get; } =
+ [
+ new(LibrarySort.RecentlyAdded, "Недавно добавленные"),
+ new(LibrarySort.TitleAscending, "По названию"),
+ new(LibrarySort.LongestFirst, "Сначала длинные"),
+ new(LibrarySort.LargestFirst, "Сначала большие"),
+ ];
+}
diff --git a/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs b/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs
new file mode 100644
index 0000000..4565770
--- /dev/null
+++ b/src/PLib.Desktop/ViewModels/VideoCardViewModel.cs
@@ -0,0 +1,74 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using PLib.Desktop.Services;
+using PLib.Domain.Videos;
+
+namespace PLib.Desktop.ViewModels;
+
+/// One card in the library grid.
+public sealed partial class VideoCardViewModel : ObservableObject
+{
+ private readonly ISystemShell _shell;
+
+ public VideoCardViewModel(VideoItem item, ISystemShell shell)
+ {
+ _shell = shell;
+ Id = item.Id;
+ FullPath = item.FullPath;
+ Title = item.Title;
+ Apply(item);
+ }
+
+ public Guid Id { get; }
+
+ public string FullPath { get; }
+
+ public DateTimeOffset AddedAt { get; private set; }
+
+ public TimeSpan? RawDuration { get; private set; }
+
+ public long RawSizeInBytes { get; private set; }
+
+ [ObservableProperty]
+ public partial string Title { get; set; }
+
+ [ObservableProperty]
+ public partial string? ThumbnailPath { get; set; }
+
+ [ObservableProperty]
+ public partial string DurationText { get; set; } = "—";
+
+ [ObservableProperty]
+ public partial string SizeText { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ public partial string? QualityText { get; set; }
+
+ /// True while the poster frame has not been produced yet.
+ [ObservableProperty]
+ public partial bool IsPending { get; set; } = true;
+
+ /// Copies the current state of the entity into the card.
+ public void Apply(VideoItem item)
+ {
+ Title = item.Title;
+ ThumbnailPath = item.ThumbnailPath;
+ DurationText = DisplayText.Duration(item.Duration);
+ SizeText = DisplayText.FileSize(item.SizeInBytes);
+ QualityText = DisplayText.Quality(item.Width, item.Height);
+ AddedAt = item.AddedAt;
+ RawDuration = item.Duration;
+ RawSizeInBytes = item.SizeInBytes;
+ IsPending = item.ThumbnailPath is null;
+ }
+
+ [RelayCommand]
+ private void Play() => _shell.OpenFile(FullPath);
+
+ [RelayCommand]
+ private void Reveal() => _shell.RevealInFileManager(FullPath);
+
+ public bool Matches(string term) =>
+ Title.Contains(term, StringComparison.CurrentCultureIgnoreCase) ||
+ FullPath.Contains(term, StringComparison.CurrentCultureIgnoreCase);
+}
diff --git a/src/PLib.Desktop/Views/MainWindow.axaml b/src/PLib.Desktop/Views/MainWindow.axaml
new file mode 100644
index 0000000..01a550b
--- /dev/null
+++ b/src/PLib.Desktop/Views/MainWindow.axaml
@@ -0,0 +1,234 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PLib.Desktop/Views/MainWindow.axaml.cs b/src/PLib.Desktop/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..b844389
--- /dev/null
+++ b/src/PLib.Desktop/Views/MainWindow.axaml.cs
@@ -0,0 +1,8 @@
+using Avalonia.Controls;
+
+namespace PLib.Desktop.Views;
+
+public sealed partial class MainWindow : Window
+{
+ public MainWindow() => InitializeComponent();
+}
diff --git a/src/PLib.Desktop/app.manifest b/src/PLib.Desktop/app.manifest
new file mode 100644
index 0000000..a6badfa
--- /dev/null
+++ b/src/PLib.Desktop/app.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ true/pm
+ permonitorv2
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PLib.Desktop/appsettings.json b/src/PLib.Desktop/appsettings.json
new file mode 100644
index 0000000..37c6160
--- /dev/null
+++ b/src/PLib.Desktop/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Library": {
+ "Folders": [],
+ "ThumbnailWidth": 480,
+ "ThumbnailPositionRatio": 0.15,
+ "MaxIndexingConcurrency": 4,
+ "MinimumFileSizeInBytes": 65536
+ }
+}
diff --git a/src/PLib.Domain/PLib.Domain.csproj b/src/PLib.Domain/PLib.Domain.csproj
new file mode 100644
index 0000000..59642d3
--- /dev/null
+++ b/src/PLib.Domain/PLib.Domain.csproj
@@ -0,0 +1,7 @@
+
+
+
+ PLib.Domain
+
+
+
diff --git a/src/PLib.Domain/Videos/VideoItem.cs b/src/PLib.Domain/Videos/VideoItem.cs
new file mode 100644
index 0000000..f4d1d95
--- /dev/null
+++ b/src/PLib.Domain/Videos/VideoItem.cs
@@ -0,0 +1,103 @@
+namespace PLib.Domain.Videos;
+
+///
+/// A single video file that belongs to the library.
+///
+///
+/// The absolute path is the natural identity of a video: the library is a view over the
+/// file system, so two entries pointing at the same path are the same video. Mutation goes
+/// through explicit methods so that the entity can never end up half-updated.
+///
+public sealed class VideoItem
+{
+ /// Required by EF Core materialization; do not use from application code.
+ private VideoItem()
+ {
+ FullPath = null!;
+ Title = null!;
+ }
+
+ public VideoItem(string fullPath, string title, long sizeInBytes, DateTimeOffset fileModifiedAt)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(fullPath);
+ ArgumentException.ThrowIfNullOrWhiteSpace(title);
+ ArgumentOutOfRangeException.ThrowIfNegative(sizeInBytes);
+
+ Id = Guid.CreateVersion7();
+ FullPath = fullPath;
+ Title = title;
+ SizeInBytes = sizeInBytes;
+ FileModifiedAt = fileModifiedAt;
+ AddedAt = DateTimeOffset.UtcNow;
+ }
+
+ public Guid Id { get; private set; }
+
+ /// Absolute path of the file on disk. Unique within the library.
+ public string FullPath { get; private set; }
+
+ /// Human readable name; defaults to the file name without extension.
+ public string Title { get; private set; }
+
+ public long SizeInBytes { get; private set; }
+
+ public TimeSpan? Duration { get; private set; }
+
+ public int? Width { get; private set; }
+
+ public int? Height { get; private set; }
+
+ public string? VideoCodec { get; private set; }
+
+ /// Absolute path of the generated poster frame, or null if none exists yet.
+ public string? ThumbnailPath { get; private set; }
+
+ /// Last write time of the file when it was last indexed.
+ public DateTimeOffset FileModifiedAt { get; private set; }
+
+ public DateTimeOffset AddedAt { get; private set; }
+
+ /// True once the file has been probed and a poster frame produced.
+ public bool IsIndexed => Duration is not null && ThumbnailPath is not null;
+
+ public void Rename(string title)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(title);
+ Title = title;
+ }
+
+ public void ApplyTechnicalInfo(VideoTechnicalInfo info)
+ {
+ Duration = info.Duration;
+ Width = info.Width;
+ Height = info.Height;
+ VideoCodec = info.VideoCodec;
+ }
+
+ public void AttachThumbnail(string thumbnailPath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(thumbnailPath);
+ ThumbnailPath = thumbnailPath;
+ }
+
+ public void DetachThumbnail() => ThumbnailPath = null;
+
+ ///
+ /// Refreshes the file system facts after the file changed on disk, and invalidates
+ /// everything that was derived from the previous revision of the file.
+ ///
+ public void RefreshFileFacts(long sizeInBytes, DateTimeOffset fileModifiedAt)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(sizeInBytes);
+
+ if (SizeInBytes == sizeInBytes && FileModifiedAt == fileModifiedAt)
+ {
+ return;
+ }
+
+ SizeInBytes = sizeInBytes;
+ FileModifiedAt = fileModifiedAt;
+ ApplyTechnicalInfo(VideoTechnicalInfo.Unknown);
+ DetachThumbnail();
+ }
+}
diff --git a/src/PLib.Domain/Videos/VideoTechnicalInfo.cs b/src/PLib.Domain/Videos/VideoTechnicalInfo.cs
new file mode 100644
index 0000000..2b6b388
--- /dev/null
+++ b/src/PLib.Domain/Videos/VideoTechnicalInfo.cs
@@ -0,0 +1,18 @@
+namespace PLib.Domain.Videos;
+
+///
+/// Technical facts about a media file that are discovered by probing it,
+/// as opposed to facts we already know from the file system entry itself.
+///
+/// Playback duration, or null if the container did not report one.
+/// Width of the primary video stream in pixels.
+/// Height of the primary video stream in pixels.
+/// Short codec name of the primary video stream, e.g. h264.
+public readonly record struct VideoTechnicalInfo(
+ TimeSpan? Duration,
+ int? Width,
+ int? Height,
+ string? VideoCodec)
+{
+ public static VideoTechnicalInfo Unknown => default;
+}
diff --git a/src/PLib.Infrastructure/DependencyInjection.cs b/src/PLib.Infrastructure/DependencyInjection.cs
new file mode 100644
index 0000000..bf95328
--- /dev/null
+++ b/src/PLib.Infrastructure/DependencyInjection.cs
@@ -0,0 +1,48 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using PLib.Application.Abstractions;
+using PLib.Application.Library;
+using PLib.Infrastructure.Media;
+using PLib.Infrastructure.Persistence;
+using PLib.Infrastructure.Storage;
+
+namespace PLib.Infrastructure;
+
+public static class DependencyInjection
+{
+ ///
+ /// Registers everything the application layer declares as an abstraction. The composition
+ /// root (the UI project) never sees EF Core or ffmpeg types directly.
+ ///
+ public static IServiceCollection AddPLibInfrastructure(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ services.AddOptions()
+ .Bind(configuration.GetSection(LibraryOptions.SectionName))
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
+
+ // TryAdd so a composition root that already needed the paths (to locate the user
+ // settings file before the container exists) can share its own instance.
+ services.TryAddSingleton();
+
+ services.AddDbContext((provider, builder) =>
+ {
+ var paths = provider.GetRequiredService();
+ builder.UseSqlite($"Data Source={paths.DatabaseFile}");
+ });
+
+ services.AddScoped();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddScoped();
+
+ services.AddHostedService();
+
+ return services;
+ }
+}
diff --git a/src/PLib.Infrastructure/Media/FfmpegMediaProbe.cs b/src/PLib.Infrastructure/Media/FfmpegMediaProbe.cs
new file mode 100644
index 0000000..719b733
--- /dev/null
+++ b/src/PLib.Infrastructure/Media/FfmpegMediaProbe.cs
@@ -0,0 +1,35 @@
+using FFMpegCore;
+using Microsoft.Extensions.Logging;
+using PLib.Application.Abstractions;
+using PLib.Domain.Videos;
+
+namespace PLib.Infrastructure.Media;
+
+///
+public sealed class FfmpegMediaProbe(ILogger logger) : IMediaProbe
+{
+ public async Task ProbeAsync(string fullPath, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var analysis = await FFProbe.AnalyseAsync(fullPath, cancellationToken: cancellationToken);
+ var video = analysis.PrimaryVideoStream;
+
+ return new VideoTechnicalInfo(
+ analysis.Duration > TimeSpan.Zero ? analysis.Duration : null,
+ video?.Width,
+ video?.Height,
+ video?.CodecName);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // A single unreadable file must not abort the scan.
+ logger.LogWarning(ex, "Could not probe {Path}", fullPath);
+ return VideoTechnicalInfo.Unknown;
+ }
+ }
+}
diff --git a/src/PLib.Infrastructure/Media/FfmpegThumbnailGenerator.cs b/src/PLib.Infrastructure/Media/FfmpegThumbnailGenerator.cs
new file mode 100644
index 0000000..1dc9940
--- /dev/null
+++ b/src/PLib.Infrastructure/Media/FfmpegThumbnailGenerator.cs
@@ -0,0 +1,111 @@
+using System.Security.Cryptography;
+using System.Text;
+using FFMpegCore;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using PLib.Application.Abstractions;
+using PLib.Application.Library;
+using PLib.Infrastructure.Storage;
+
+namespace PLib.Infrastructure.Media;
+
+///
+public sealed class FfmpegThumbnailGenerator(
+ IAppPaths paths,
+ IOptions options,
+ ILogger logger) : IThumbnailGenerator
+{
+ /// Fallback capture position for files whose duration we could not read.
+ private static readonly TimeSpan BlindCapturePosition = TimeSpan.FromSeconds(5);
+
+ private readonly LibraryOptions _options = options.Value;
+
+ public async Task GetOrCreateAsync(
+ string videoPath,
+ TimeSpan? duration,
+ CancellationToken cancellationToken = default)
+ {
+ var file = new FileInfo(videoPath);
+
+ if (!file.Exists)
+ {
+ return null;
+ }
+
+ var target = Path.Combine(paths.ThumbnailDirectory, $"{BuildCacheKey(file)}.jpg");
+
+ if (File.Exists(target))
+ {
+ return target;
+ }
+
+ // Render to a private temp file first so a crash or cancellation can never leave a
+ // truncated JPEG behind that later runs would happily treat as a valid cache hit.
+ var staging = Path.Combine(paths.ThumbnailDirectory, $"{Guid.CreateVersion7()}.tmp");
+
+ try
+ {
+ var succeeded = await RenderAsync(videoPath, staging, CapturePositionFor(duration), cancellationToken);
+
+ if (!succeeded)
+ {
+ return null;
+ }
+
+ File.Move(staging, target, overwrite: true);
+ return target;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Could not create a thumbnail for {Path}", videoPath);
+ return null;
+ }
+ finally
+ {
+ if (File.Exists(staging))
+ {
+ File.Delete(staging);
+ }
+ }
+ }
+
+ private async Task RenderAsync(
+ string videoPath,
+ string outputPath,
+ TimeSpan capturePosition,
+ CancellationToken cancellationToken)
+ {
+ // Seeking on the input (rather than the output) makes ffmpeg jump straight to the
+ // keyframe instead of decoding everything before it — orders of magnitude faster.
+ // Height -2 lets ffmpeg keep the aspect ratio while staying encoder friendly.
+ return await FFMpegArguments
+ .FromFileInput(videoPath, verifyExists: true, input => input.Seek(capturePosition))
+ .OutputToFile(outputPath, overwrite: true, output => output
+ .WithVideoFilters(filter => filter.Scale(_options.ThumbnailWidth, -2))
+ .WithFrameOutputCount(1)
+ .WithCustomArgument("-q:v 3")
+ .ForceFormat("image2"))
+ .CancellableThrough(cancellationToken)
+ .ProcessAsynchronously(throwOnError: false);
+ }
+
+ private TimeSpan CapturePositionFor(TimeSpan? duration) =>
+ duration is { } value && value > TimeSpan.Zero
+ ? value * _options.ThumbnailPositionRatio
+ : BlindCapturePosition;
+
+ ///
+ /// Keys the cache by path plus size plus timestamp, so replacing a file on disk
+ /// naturally produces a different key rather than a stale poster frame.
+ ///
+ private static string BuildCacheKey(FileInfo file)
+ {
+ var seed = $"{file.FullName}|{file.Length}|{file.LastWriteTimeUtc.Ticks}";
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
+ return Convert.ToHexStringLower(hash)[..32];
+ }
+}
diff --git a/src/PLib.Infrastructure/Media/FileSystemVideoScanner.cs b/src/PLib.Infrastructure/Media/FileSystemVideoScanner.cs
new file mode 100644
index 0000000..ecd30b4
--- /dev/null
+++ b/src/PLib.Infrastructure/Media/FileSystemVideoScanner.cs
@@ -0,0 +1,52 @@
+using System.Runtime.CompilerServices;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using PLib.Application.Abstractions;
+using PLib.Application.Library;
+
+namespace PLib.Infrastructure.Media;
+
+///
+public sealed class FileSystemVideoScanner(
+ IOptions options,
+ ILogger logger) : IVideoFileScanner
+{
+ private readonly HashSet _extensions =
+ new(options.Value.VideoExtensions, StringComparer.OrdinalIgnoreCase);
+
+ public async IAsyncEnumerable ScanAsync(
+ string rootFolder,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ // Walking a large tree blocks; hand the caller back its thread before we start.
+ await Task.Yield();
+
+ if (!Directory.Exists(rootFolder))
+ {
+ logger.LogWarning("Library folder {Folder} does not exist and was skipped", rootFolder);
+ yield break;
+ }
+
+ // IgnoreInaccessible keeps a single protected subfolder from aborting the whole walk.
+ var enumerationOptions = new EnumerationOptions
+ {
+ RecurseSubdirectories = true,
+ IgnoreInaccessible = true,
+ AttributesToSkip = FileAttributes.Hidden | FileAttributes.System,
+ };
+
+ // Enumerating FileInfo (rather than paths) reuses the metadata the OS already
+ // returned for each directory entry, so size and timestamp cost no extra syscall.
+ foreach (var file in new DirectoryInfo(rootFolder).EnumerateFiles("*", enumerationOptions))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!_extensions.Contains(file.Extension))
+ {
+ continue;
+ }
+
+ yield return new DiscoveredVideoFile(file.FullName, file.Length, file.LastWriteTimeUtc);
+ }
+ }
+}
diff --git a/src/PLib.Infrastructure/PLib.Infrastructure.csproj b/src/PLib.Infrastructure/PLib.Infrastructure.csproj
new file mode 100644
index 0000000..b847659
--- /dev/null
+++ b/src/PLib.Infrastructure/PLib.Infrastructure.csproj
@@ -0,0 +1,18 @@
+
+
+
+ PLib.Infrastructure
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PLib.Infrastructure/Persistence/DatabaseInitializer.cs b/src/PLib.Infrastructure/Persistence/DatabaseInitializer.cs
new file mode 100644
index 0000000..28cafb1
--- /dev/null
+++ b/src/PLib.Infrastructure/Persistence/DatabaseInitializer.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace PLib.Infrastructure.Persistence;
+
+///
+/// Brings the local database up to date before the first window is shown.
+///
+///
+/// While the schema is still moving we create it from the model. Once the shape settles this
+/// becomes MigrateAsync plus a checked-in migration history.
+///
+public sealed class DatabaseInitializer(
+ IServiceScopeFactory scopeFactory,
+ ILogger logger) : IHostedService
+{
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ await using var scope = scopeFactory.CreateAsyncScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ var created = await dbContext.Database.EnsureCreatedAsync(cancellationToken);
+ logger.LogInformation("Library database ready (created: {Created})", created);
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+}
diff --git a/src/PLib.Infrastructure/Persistence/EfVideoRepository.cs b/src/PLib.Infrastructure/Persistence/EfVideoRepository.cs
new file mode 100644
index 0000000..9bb479a
--- /dev/null
+++ b/src/PLib.Infrastructure/Persistence/EfVideoRepository.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore;
+using PLib.Application.Abstractions;
+using PLib.Domain.Videos;
+
+namespace PLib.Infrastructure.Persistence;
+
+///
+public sealed class EfVideoRepository(LibraryDbContext dbContext) : IVideoRepository
+{
+ public async Task> GetAllAsync(CancellationToken cancellationToken = default) =>
+ await dbContext.Videos
+ .OrderByDescending(x => x.AddedAt)
+ .ToListAsync(cancellationToken);
+
+ public Task FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
+ dbContext.Videos.FirstOrDefaultAsync(x => x.FullPath == fullPath, cancellationToken);
+
+ public async Task AddAsync(VideoItem item, CancellationToken cancellationToken = default) =>
+ await dbContext.Videos.AddAsync(item, cancellationToken);
+
+ public Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default)
+ {
+ dbContext.Videos.Remove(item);
+ return Task.CompletedTask;
+ }
+
+ public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
+ dbContext.SaveChangesAsync(cancellationToken);
+}
diff --git a/src/PLib.Infrastructure/Persistence/LibraryDbContext.cs b/src/PLib.Infrastructure/Persistence/LibraryDbContext.cs
new file mode 100644
index 0000000..85e57d1
--- /dev/null
+++ b/src/PLib.Infrastructure/Persistence/LibraryDbContext.cs
@@ -0,0 +1,15 @@
+using Microsoft.EntityFrameworkCore;
+using PLib.Domain.Videos;
+
+namespace PLib.Infrastructure.Persistence;
+
+public sealed class LibraryDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Videos => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(LibraryDbContext).Assembly);
+ base.OnModelCreating(modelBuilder);
+ }
+}
diff --git a/src/PLib.Infrastructure/Persistence/VideoItemConfiguration.cs b/src/PLib.Infrastructure/Persistence/VideoItemConfiguration.cs
new file mode 100644
index 0000000..50809e8
--- /dev/null
+++ b/src/PLib.Infrastructure/Persistence/VideoItemConfiguration.cs
@@ -0,0 +1,51 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using PLib.Domain.Videos;
+
+namespace PLib.Infrastructure.Persistence;
+
+internal sealed class VideoItemConfiguration : IEntityTypeConfiguration
+{
+ ///
+ /// SQLite refuses to ORDER BY a DateTimeOffset because its default TEXT representation
+ /// carries an offset and therefore does not sort chronologically. Storing UTC ticks keeps
+ /// the column both sortable and indexable.
+ ///
+ private static readonly ValueConverter UtcTicksConverter = new(
+ value => value.UtcTicks,
+ ticks => new DateTimeOffset(ticks, TimeSpan.Zero));
+
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("Videos");
+
+ builder.HasKey(x => x.Id);
+
+ builder.Property(x => x.AddedAt).HasConversion(UtcTicksConverter);
+ builder.Property(x => x.FileModifiedAt).HasConversion(UtcTicksConverter);
+
+ builder.Property(x => x.FullPath)
+ .IsRequired()
+ .HasMaxLength(1024);
+
+ builder.HasIndex(x => x.FullPath)
+ .IsUnique();
+
+ builder.Property(x => x.Title)
+ .IsRequired()
+ .HasMaxLength(512);
+
+ builder.Property(x => x.VideoCodec)
+ .HasMaxLength(64);
+
+ builder.Property(x => x.ThumbnailPath)
+ .HasMaxLength(1024);
+
+ // Sorting the grid by "recently added" is the default view, so it gets an index.
+ builder.HasIndex(x => x.AddedAt);
+
+ // IsIndexed is derived from other columns and must not become a table column.
+ builder.Ignore(x => x.IsIndexed);
+ }
+}
diff --git a/src/PLib.Infrastructure/Storage/AppPaths.cs b/src/PLib.Infrastructure/Storage/AppPaths.cs
new file mode 100644
index 0000000..e5255ab
--- /dev/null
+++ b/src/PLib.Infrastructure/Storage/AppPaths.cs
@@ -0,0 +1,25 @@
+namespace PLib.Infrastructure.Storage;
+
+///
+public sealed class AppPaths : IAppPaths
+{
+ public AppPaths()
+ {
+ var localAppData = Environment.GetFolderPath(
+ Environment.SpecialFolder.LocalApplicationData,
+ Environment.SpecialFolderOption.Create);
+
+ DataDirectory = Path.Combine(localAppData, "PLib");
+ ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
+ DatabaseFile = Path.Combine(DataDirectory, "library.db");
+
+ Directory.CreateDirectory(DataDirectory);
+ Directory.CreateDirectory(ThumbnailDirectory);
+ }
+
+ public string DataDirectory { get; }
+
+ public string ThumbnailDirectory { get; }
+
+ public string DatabaseFile { get; }
+}
diff --git a/src/PLib.Infrastructure/Storage/IAppPaths.cs b/src/PLib.Infrastructure/Storage/IAppPaths.cs
new file mode 100644
index 0000000..f5ced71
--- /dev/null
+++ b/src/PLib.Infrastructure/Storage/IAppPaths.cs
@@ -0,0 +1,14 @@
+namespace PLib.Infrastructure.Storage;
+
+/// Where the application keeps the data it owns on the local machine.
+public interface IAppPaths
+{
+ /// Root of the per-user data directory; created on first access.
+ string DataDirectory { get; }
+
+ /// Directory holding cached poster frames.
+ string ThumbnailDirectory { get; }
+
+ /// Full path of the SQLite database file.
+ string DatabaseFile { get; }
+}
diff --git a/tests/PLib.Tests/Domain/VideoItemTests.cs b/tests/PLib.Tests/Domain/VideoItemTests.cs
new file mode 100644
index 0000000..6ad9e98
--- /dev/null
+++ b/tests/PLib.Tests/Domain/VideoItemTests.cs
@@ -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(() => new VideoItem(path, title, 1, DateTimeOffset.UnixEpoch));
+
+ [Fact]
+ public void An_item_cannot_have_a_negative_size() =>
+ Should.Throw(
+ () => new VideoItem(@"C:\videos\clip.mp4", "clip", -1, DateTimeOffset.UnixEpoch));
+}
diff --git a/tests/PLib.Tests/GlobalUsings.cs b/tests/PLib.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/tests/PLib.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/tests/PLib.Tests/Library/InMemoryVideoRepository.cs b/tests/PLib.Tests/Library/InMemoryVideoRepository.cs
new file mode 100644
index 0000000..be7464d
--- /dev/null
+++ b/tests/PLib.Tests/Library/InMemoryVideoRepository.cs
@@ -0,0 +1,50 @@
+using PLib.Application.Abstractions;
+using PLib.Application.Library;
+using PLib.Domain.Videos;
+
+namespace PLib.Tests.Library;
+
+///
+/// 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.
+///
+internal sealed class InMemoryVideoRepository : IVideoRepository
+{
+ private readonly Dictionary _items = new(LibraryPathComparer.Instance);
+
+ public int SaveCount { get; private set; }
+
+ public IReadOnlyCollection Items => _items.Values;
+
+ public void Seed(params VideoItem[] items)
+ {
+ foreach (var item in items)
+ {
+ _items[item.FullPath] = item;
+ }
+ }
+
+ public Task> GetAllAsync(CancellationToken cancellationToken = default) =>
+ Task.FromResult>([.. _items.Values]);
+
+ public Task 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;
+ }
+}
diff --git a/tests/PLib.Tests/Library/LibraryServiceTests.cs b/tests/PLib.Tests/Library/LibraryServiceTests.cs
new file mode 100644
index 0000000..c697cd9
--- /dev/null
+++ b/tests/PLib.Tests/Library/LibraryServiceTests.cs
@@ -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();
+ private readonly IMediaProbe _probe = Substitute.For();
+ private readonly IThumbnailGenerator _thumbnails = Substitute.For();
+
+ public LibraryServiceTests()
+ {
+ _probe.ProbeAsync(Arg.Any(), Arg.Any())
+ .Returns(new VideoTechnicalInfo(TimeSpan.FromMinutes(2), 1920, 1080, "h264"));
+
+ _thumbnails.GetOrCreateAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(callInfo => $@"C:\cache\{Path.GetFileNameWithoutExtension(callInfo.Arg())}.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().Count().ShouldBe(2);
+ events.OfType().Count().ShouldBe(2);
+ events.OfType().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().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(), Arg.Any());
+ }
+
+ [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());
+ _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().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(), Arg.Any())
+ .Returns(_ => files.ToAsyncEnumerable());
+
+ private LibraryService CreateService(LibraryOptions? options = null) => new(
+ _repository,
+ _scanner,
+ _probe,
+ _thumbnails,
+ Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
+ NullLogger.Instance);
+
+ private static async Task> CollectAsync(
+ LibraryService service,
+ params string[] folders)
+ {
+ var events = new List();
+
+ 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 ToAsyncEnumerable(this IEnumerable source)
+ {
+ foreach (var item in source)
+ {
+ yield return item;
+ }
+
+ await Task.CompletedTask;
+ }
+}
diff --git a/tests/PLib.Tests/PLib.Tests.csproj b/tests/PLib.Tests/PLib.Tests.csproj
new file mode 100644
index 0000000..ed65797
--- /dev/null
+++ b/tests/PLib.Tests/PLib.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ PLib.Tests
+ false
+ Exe
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+