Refactor PLib video library manager to use ReactiveUI, replacing CommunityToolkit.Mvvm. Update dependencies, enhance thumbnail caching logic, and improve UI responsiveness with reactive commands. Remove obsolete PLib.slnx file and update README.md to reflect changes.

This commit is contained in:
Leonid Pershin
2026-08-08 11:37:36 +03:00
parent bf4a3bb922
commit 625eae7ead
18 changed files with 693 additions and 359 deletions
+4 -1
View File
@@ -15,7 +15,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup Label="MVVM / Composition"> <ItemGroup Label="MVVM / Composition">
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <!-- ReactiveUI.Avalonia replaces the retired Avalonia.ReactiveUI and tracks Avalonia's own version. -->
<PackageVersion Include="ReactiveUI.Avalonia" Version="12.1.1" />
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="3.2.0" />
<PackageVersion Include="DynamicData" Version="9.4.33" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.10" />
+59
View File
@@ -0,0 +1,59 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PLib.Application", "src\PLib.Application\PLib.Application.csproj", "{CA433FFF-2A0D-D816-7392-E8545C23C7AC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PLib.Desktop", "src\PLib.Desktop\PLib.Desktop.csproj", "{7A7DE065-F7FF-F41F-D49F-113744DFA193}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PLib.Domain", "src\PLib.Domain\PLib.Domain.csproj", "{78C1EA0D-4C63-AB01-C682-E4CA14FBA0B3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PLib.Infrastructure", "src\PLib.Infrastructure\PLib.Infrastructure.csproj", "{277BDC30-D1EA-F232-4993-B240D13E0B25}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PLib.Tests", "tests\PLib.Tests\PLib.Tests.csproj", "{D5765D3B-9520-CF00-7091-4E033549389B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{CA433FFF-2A0D-D816-7392-E8545C23C7AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CA433FFF-2A0D-D816-7392-E8545C23C7AC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CA433FFF-2A0D-D816-7392-E8545C23C7AC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CA433FFF-2A0D-D816-7392-E8545C23C7AC}.Release|Any CPU.Build.0 = Release|Any CPU
{7A7DE065-F7FF-F41F-D49F-113744DFA193}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7A7DE065-F7FF-F41F-D49F-113744DFA193}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7A7DE065-F7FF-F41F-D49F-113744DFA193}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7A7DE065-F7FF-F41F-D49F-113744DFA193}.Release|Any CPU.Build.0 = Release|Any CPU
{78C1EA0D-4C63-AB01-C682-E4CA14FBA0B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{78C1EA0D-4C63-AB01-C682-E4CA14FBA0B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{78C1EA0D-4C63-AB01-C682-E4CA14FBA0B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{78C1EA0D-4C63-AB01-C682-E4CA14FBA0B3}.Release|Any CPU.Build.0 = Release|Any CPU
{277BDC30-D1EA-F232-4993-B240D13E0B25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{277BDC30-D1EA-F232-4993-B240D13E0B25}.Debug|Any CPU.Build.0 = Debug|Any CPU
{277BDC30-D1EA-F232-4993-B240D13E0B25}.Release|Any CPU.ActiveCfg = Release|Any CPU
{277BDC30-D1EA-F232-4993-B240D13E0B25}.Release|Any CPU.Build.0 = Release|Any CPU
{D5765D3B-9520-CF00-7091-4E033549389B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D5765D3B-9520-CF00-7091-4E033549389B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D5765D3B-9520-CF00-7091-4E033549389B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D5765D3B-9520-CF00-7091-4E033549389B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{CA433FFF-2A0D-D816-7392-E8545C23C7AC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{7A7DE065-F7FF-F41F-D49F-113744DFA193} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{78C1EA0D-4C63-AB01-C682-E4CA14FBA0B3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{277BDC30-D1EA-F232-4993-B240D13E0B25} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{D5765D3B-9520-CF00-7091-4E033549389B} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {4BD5DD2D-7DF1-4EA6-A74B-D2B04FDE178C}
EndGlobalSection
EndGlobal
-11
View File
@@ -1,11 +0,0 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/PLib.Application/PLib.Application.csproj" />
<Project Path="src/PLib.Desktop/PLib.Desktop.csproj" />
<Project Path="src/PLib.Domain/PLib.Domain.csproj" />
<Project Path="src/PLib.Infrastructure/PLib.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/PLib.Tests/PLib.Tests.csproj" />
</Folder>
</Solution>
+79 -66
View File
@@ -1,66 +1,79 @@
# PLib # PLib
Менеджер видеотеки на Avalonia: сканирует папки, вытаскивает превью через ffmpeg и Менеджер видеотеки на Avalonia: сканирует папки, вытаскивает превью через ffmpeg и
показывает всё сеткой карточек. показывает всё сеткой карточек.
## Что уже работает ## Что уже работает
- Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется. - Сканирование указанных папок, инкрементальное — файл, который не изменился, не переиндексируется.
- Метаданные (длительность, разрешение, кодек) через ffprobe. - Метаданные (длительность, разрешение, кодек) через ffprobe.
- Постеры кадром из видео через ffmpeg, с кэшем на диске. - Постеры кадром из видео через ffmpeg, с кэшем на диске.
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка. - Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
- Светлая и тёмная темы. - Светлая и тёмная темы.
- Клик или Enter по карточке — открыть в системном плеере, правая кнопка — контекстное меню. - Клик или Enter по карточке — открыть в системном плеере, правая кнопка — контекстное меню.
## Требования ## Требования
- .NET 10 SDK - .NET 10 SDK
- `ffmpeg` и `ffprobe` в `PATH` - `ffmpeg` и `ffprobe` в `PATH`
## Запуск ## Запуск
```bash ```bash
dotnet run --project src/PLib.Desktop dotnet run --project src/PLib.Desktop
``` ```
```bash ```bash
dotnet test dotnet test
``` ```
## Архитектура ## Архитектура
Четыре слоя, зависимости направлены только внутрь: Четыре слоя, зависимости направлены только внутрь:
| Проект | Отвечает за | Знает о | | Проект | Отвечает за | Знает о |
| --- | --- | --- | | --- | --- | --- |
| `PLib.Domain` | Сущность `VideoItem` и её инварианты | ни о чём | | `PLib.Domain` | Сущность `VideoItem` и её инварианты | ни о чём |
| `PLib.Application` | Сценарии (`LibraryService`) и абстракции портов | Domain | | `PLib.Application` | Сценарии (`LibraryService`) и абстракции портов | Domain |
| `PLib.Infrastructure` | EF Core + SQLite, ffmpeg, файловая система | Application | | `PLib.Infrastructure` | EF Core + SQLite, ffmpeg, файловая система | Application |
| `PLib.Desktop` | Avalonia, ViewModel'и, composition root | Infrastructure | | `PLib.Desktop` | Avalonia, ViewModel'и, composition root | Infrastructure |
Ключевые решения: Ключевые решения:
- **Сканирование — поток событий.** `ILibraryService.ScanAsync` возвращает - **MVVM на ReactiveUI.** Свойства — `[Reactive]` из `ReactiveUI.SourceGenerators`, команды —
`IAsyncEnumerable<LibraryScanEvent>`: карточки появляются по мере находок, а не после `ReactiveCommand`, производные значения (`IsScanning`, `IsEmpty`) — `ToProperty`. Отмена
завершения всего прохода. Тяжёлая часть (ffprobe + ffmpeg) идёт параллельно через сканирования сделана штатным способом: скан живёт как observable, а `CancelScanCommand`
`Parallel.ForEachAsync`, результаты собираются в `Channel` и применяются к сущностям просто отписывает его через `TakeUntil`, что отменяет `CancellationToken`.
по одному — трекер изменений EF не потокобезопасен. - **Сетка — проекция DynamicData, а не пересборка.** `SourceCache``AutoRefresh``Filter`
- **Вся работа вне UI-потока.** ViewModel оборачивает конвейер в `Task.Run` и возвращает `SortAndBind` отдаёт диффы: добавился один файл — одна вставка в нужную позицию. Скролл,
каждое событие в UI явно через `Dispatcher.UIThread`. контейнеры `ItemsRepeater` и уже загруженные превью остаются на месте. Поиск дебаунсится
- **Превью живут только пока видны.** `AsyncImage` запрашивает битмап при попадании в на 200 мс, изменения карточек во время скана коалесцируются в 250 мс.
визуальное дерево и отпускает при выходе; `ThumbnailCache` — LRU на 256 записей с - **Сканирование — поток событий.** `ILibraryService.ScanAsync` возвращает
декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки. `IAsyncEnumerable<LibraryScanEvent>`: карточки появляются по мере находок, а не после
- **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт завершения всего прохода. Тяжёлая часть (ffprobe + ffmpeg) идёт параллельно через
`IServiceScopeFactory` и создаёт scope на каждый вызов. `Parallel.ForEachAsync`, результаты собираются в `Channel` и применяются к сущностям
по одному — трекер изменений EF не потокобезопасен.
## Данные - **Вся работа вне UI-потока.** ViewModel оборачивает конвейер в `Task.Run` и возвращает
каждое событие в UI явно через `Dispatcher.UIThread`.
Всё пользовательское лежит в `%LOCALAPPDATA%\PLib`: - **Превью живут только пока видны.** `AsyncImage` запрашивает битмап при попадании в
визуальное дерево и отпускает при выходе; `ThumbnailCache` — LRU на 256 записей с
- `library.db` — SQLite с метаданными; декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки.
- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла); - **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт
- `settings.json` — список папок, перечитывается на лету; `IServiceScopeFactory` и создаёт scope на каждый вызов.
- `logs/` — Serilog, ротация по дням. - **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU
на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически
Схема создаётся через `EnsureCreated`. Когда форма таблицы устоится — заменить на на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного
миграции EF Core (`DatabaseInitializer` — единственное место, которое надо будет тронуть). прохода лишние файлы вычищаются (`PurgeUnusedAsync`). Незавершённые `.tmp` удаляются
только если им больше часа — иначе можно снести рендер второго запущенного экземпляра.
## Данные
Всё пользовательское лежит в `%LOCALAPPDATA%\PLib`:
- `library.db` — SQLite с метаданными;
- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла);
- `settings.json` — список папок, перечитывается на лету;
- `logs/` — Serilog, ротация по дням.
Схема создаётся через `EnsureCreated`. Когда форма таблицы устоится — заменить на
миграции EF Core (`DatabaseInitializer` — единственное место, которое надо будет тронуть).
@@ -12,4 +12,20 @@ public interface IThumbnailGenerator
string videoPath, string videoPath,
TimeSpan? duration, TimeSpan? duration,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// True when a previously generated poster frame is still present in the cache. The
/// cache directory is ordinary user-writable storage, so a path the library remembers
/// is not proof that the file behind it still exists.
/// </summary>
bool IsAvailable(string? thumbnailPath);
/// <summary>
/// Deletes cached frames that no library item points at any more, and returns how many
/// files were removed. Call only after a complete scan: anything not in
/// <paramref name="inUsePaths"/> is treated as garbage.
/// </summary>
Task<int> PurgeUnusedAsync(
IReadOnlyCollection<string> inUsePaths,
CancellationToken cancellationToken = default);
} }
@@ -44,6 +44,16 @@ public sealed class LibraryService(
if (known.TryGetValue(file.FullPath, out var existing)) if (known.TryGetValue(file.FullPath, out var existing))
{ {
existing.RefreshFileFacts(file.SizeInBytes, file.ModifiedAt); existing.RefreshFileFacts(file.SizeInBytes, file.ModifiedAt);
// The cache directory is ordinary user storage: a poster frame we remember
// may simply have been deleted. Trusting the stored path would leave the
// card blank forever, because the item still looks indexed.
if (existing.ThumbnailPath is not null &&
!thumbnailGenerator.IsAvailable(existing.ThumbnailPath))
{
existing.DetachThumbnail();
yield return new LibraryScanEvent.ItemUpdated(existing);
}
} }
else else
{ {
@@ -79,9 +89,29 @@ public sealed class LibraryService(
} }
await repository.SaveChangesAsync(cancellationToken); await repository.SaveChangesAsync(cancellationToken);
await PurgeThumbnailCacheAsync(known.Values, cancellationToken);
yield return new LibraryScanEvent.Completed(known.Count); yield return new LibraryScanEvent.Completed(known.Count);
} }
/// <summary>
/// Drops cached frames nothing points at any more. Safe only here, at the end of a
/// completed scan, because that is the only moment the library is known to be whole —
/// running it mid-scan would delete frames of items not reconciled yet.
/// </summary>
private async Task PurgeThumbnailCacheAsync(
IEnumerable<VideoItem> library,
CancellationToken cancellationToken)
{
var inUse = library.Select(x => x.ThumbnailPath).OfType<string>().ToArray();
var removed = await thumbnailGenerator.PurgeUnusedAsync(inUse, cancellationToken);
if (removed > 0)
{
logger.LogInformation("Removed {Count} orphaned poster frames from the cache", removed);
}
}
private async Task<Dictionary<string, DiscoveredVideoFile>> DiscoverAsync( private async Task<Dictionary<string, DiscoveredVideoFile>> DiscoverAsync(
IReadOnlyList<string> folders, IReadOnlyList<string> folders,
CancellationToken cancellationToken) CancellationToken cancellationToken)
+55 -55
View File
@@ -1,55 +1,55 @@
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Threading; using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using PLib.Desktop.Controls; using PLib.Desktop.Controls;
using PLib.Desktop.Imaging; using PLib.Desktop.Imaging;
using PLib.Desktop.ViewModels; using PLib.Desktop.ViewModels;
using PLib.Desktop.Views; using PLib.Desktop.Views;
namespace PLib.Desktop; namespace PLib.Desktop;
// Fully qualified: the PLib.Application namespace shadows the Application type in this assembly. // Fully qualified: the PLib.Application namespace shadows the Application type in this assembly.
public sealed class App : Avalonia.Application public sealed class App : Avalonia.Application
{ {
private IHost? _host; private IHost? _host;
public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
{ {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
_host = AppHost.Create(desktop.Args ?? []); _host = AppHost.Create(desktop.Args ?? []);
_host.Start(); _host.Start();
AsyncImage.Loader = _host.Services.GetRequiredService<ThumbnailCache>(); AsyncImage.Loader = _host.Services.GetRequiredService<ThumbnailCache>();
var viewModel = _host.Services.GetRequiredService<MainWindowViewModel>(); var viewModel = _host.Services.GetRequiredService<MainWindowViewModel>();
desktop.MainWindow = new MainWindow { DataContext = viewModel }; desktop.MainWindow = new MainWindow { DataContext = viewModel };
desktop.Exit += OnExit; desktop.Exit += OnExit;
// Kick the first load off once the dispatcher is running, so a failure surfaces // 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. // through the view model instead of disappearing into an unobserved task.
Dispatcher.UIThread.Post( Dispatcher.UIThread.Post(
() => viewModel.InitializeCommand.Execute(null), () => viewModel.InitializeCommand.Execute().Subscribe(),
DispatcherPriority.Background); DispatcherPriority.Background);
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e)
{ {
if (_host is null) if (_host is null)
{ {
return; return;
} }
_host.StopAsync(TimeSpan.FromSeconds(3)).GetAwaiter().GetResult(); _host.StopAsync(TimeSpan.FromSeconds(3)).GetAwaiter().GetResult();
_host.Dispose(); _host.Dispose();
_host = null; _host = null;
} }
} }
+6 -1
View File
@@ -23,7 +23,12 @@
<PackageReference Include="Avalonia.Fonts.Inter" /> <PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Semi.Avalonia" /> <PackageReference Include="Semi.Avalonia" />
<PackageReference Include="Material.Icons.Avalonia" /> <PackageReference Include="Material.Icons.Avalonia" />
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="ReactiveUI.Avalonia" />
<PackageReference Include="ReactiveUI.SourceGenerators">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="DynamicData" />
<PackageReference Include="Microsoft.Extensions.Hosting" /> <PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Serilog" /> <PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" /> <PackageReference Include="Serilog.Extensions.Logging" />
+20 -18
View File
@@ -1,18 +1,20 @@
using Avalonia; using Avalonia;
using ReactiveUI.Avalonia;
namespace PLib.Desktop;
namespace PLib.Desktop;
internal static class Program
{ 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. // Avalonia must be initialised before anything touches its types, so keep Main free of
[STAThread] // any other work and let App own the application host.
public static void Main(string[] args) => BuildAvaloniaApp() [STAThread]
.StartWithClassicDesktopLifetime(args); public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
/// <summary>Also used by the XAML previewer, which requires this exact signature.</summary>
public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<App>() /// <summary>Also used by the XAML previewer, which requires this exact signature.</summary>
.UsePlatformDetect() public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure<App>()
.WithInterFont() .UsePlatformDetect()
.LogToTrace(); .UseReactiveUI(reactive => reactive.WithAvalonia())
} .WithInterFont()
.LogToTrace();
}
@@ -0,0 +1,14 @@
using System.Reactive.Disposables;
namespace PLib.Desktop.ViewModels;
internal static class DisposableExtensions
{
/// <summary>
/// Parks a subscription in the owner's bag so it dies with the owner. ReactiveUI 24 moved
/// its own <c>DisposeWith</c> into a namespace whose operator set collides with
/// System.Reactive's, so this project keeps its own two-line version instead.
/// </summary>
public static void AddTo(this IDisposable disposable, CompositeDisposable subscriptions) =>
subscriptions.Add(disposable);
}
+162 -115
View File
@@ -1,28 +1,60 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using Avalonia.Threading; using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using DynamicData;
using CommunityToolkit.Mvvm.Input; using DynamicData.Kernel;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using PLib.Application.Library; using PLib.Application.Library;
using PLib.Desktop.Services; using PLib.Desktop.Services;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
// Type alias, not a namespace import: pulling in ReactiveUI.Primitives would put a second
// set of Rx operators next to System.Reactive's and make every Select/Subscribe ambiguous.
using RxVoid = ReactiveUI.Primitives.RxVoid;
namespace PLib.Desktop.ViewModels; namespace PLib.Desktop.ViewModels;
public sealed partial class MainWindowViewModel : ObservableObject public sealed partial class MainWindowViewModel : ViewModelBase
{ {
/// <summary>How long typing has to pause before the grid is re-filtered.</summary>
private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(200);
/// <summary>
/// Card property changes are coalesced over this window. During a scan every indexed
/// file updates its card, and re-sorting on each one individually would be wasted work.
/// </summary>
private static readonly TimeSpan RefreshBuffer = TimeSpan.FromMilliseconds(250);
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptionsMonitor<LibraryOptions> _options; private readonly IOptionsMonitor<LibraryOptions> _options;
private readonly ILibrarySettingsStore _settingsStore; private readonly ILibrarySettingsStore _settingsStore;
private readonly IFolderPicker _folderPicker; private readonly IFolderPicker _folderPicker;
private readonly ISystemShell _shell; private readonly ISystemShell _shell;
private readonly IThemeService _theme;
private readonly ILogger<MainWindowViewModel> _logger; private readonly ILogger<MainWindowViewModel> _logger;
/// <summary>Every card we know about; <see cref="Videos"/> is the filtered, sorted view of it.</summary> /// <summary>
private readonly List<VideoCardViewModel> _all = []; /// The whole library, keyed by identifier. <see cref="Videos"/> is a filtered and sorted
private readonly Dictionary<Guid, VideoCardViewModel> _byId = []; /// projection of it that DynamicData keeps in sync through fine-grained changes, so the
/// grid never has to be rebuilt from scratch.
/// </summary>
private readonly SourceCache<VideoCardViewModel, Guid> _library = new(card => card.Id);
/// <summary>
/// DynamicData is built on System.Reactive, whose schedulers are a different abstraction
/// from ReactiveUI 24's. Avalonia's synchronisation context bridges the two: posting to
/// it is posting to the dispatcher.
/// </summary>
private readonly IScheduler _uiScheduler =
new SynchronizationContextScheduler(new AvaloniaSynchronizationContext());
private readonly Subject<RxVoid> _cancelScan = new();
private readonly ReadOnlyObservableCollection<VideoCardViewModel> _videos;
private readonly ObservableAsPropertyHelper<bool> _isScanning;
private readonly ObservableAsPropertyHelper<bool> _isEmpty;
public MainWindowViewModel( public MainWindowViewModel(
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
@@ -38,77 +70,151 @@ public sealed partial class MainWindowViewModel : ObservableObject
_settingsStore = settingsStore; _settingsStore = settingsStore;
_folderPicker = folderPicker; _folderPicker = folderPicker;
_shell = shell; _shell = shell;
_theme = theme;
_logger = logger; _logger = logger;
SelectedSort = SortOption.All[0]; SelectedSort = SortOption.All[0];
InitializeCommand = ReactiveCommand.CreateFromTask(InitializeAsync);
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
ToggleThemeCommand = ReactiveCommand.Create(theme.Toggle);
// Cancellation the ReactiveUI way: the scan runs as an observable, and cancelling
// simply unsubscribes it, which cancels the token Observable.StartAsync handed out.
ScanCommand = ReactiveCommand.CreateFromObservable(
() => Observable.StartAsync(ScanAsync).Select(_ => RxVoid.Default).TakeUntil(_cancelScan));
CancelScanCommand = ReactiveCommand.Create(
() => _cancelScan.OnNext(RxVoid.Default),
ScanCommand.IsExecuting);
_isScanning = ScanCommand.IsExecuting.ToProperty(this, x => x.IsScanning);
BuildLibraryView(out _videos, out _isEmpty);
ObserveCommandFailures();
} }
public ObservableCollection<VideoCardViewModel> Videos { get; } = []; /// <summary>The cards actually on screen, in the order the user asked for.</summary>
public ReadOnlyObservableCollection<VideoCardViewModel> Videos => _videos;
public IReadOnlyList<SortOption> SortOptions => SortOption.All; public IReadOnlyList<SortOption> SortOptions => SortOption.All;
public IReadOnlyList<string> Folders => [.. _options.CurrentValue.Folders]; public bool IsScanning => _isScanning.Value;
[ObservableProperty] /// <summary>True when there is nothing to show and no scan is running to change that.</summary>
public partial string SearchText { get; set; } = string.Empty; public bool IsEmpty => _isEmpty.Value;
[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; }
/// <summary>True when the library is empty and there is nothing to show but the call to action.</summary>
public bool IsEmpty => Videos.Count == 0 && !IsScanning;
public bool HasFolders => _options.CurrentValue.Folders.Count > 0; public bool HasFolders => _options.CurrentValue.Folders.Count > 0;
partial void OnSearchTextChanged(string value) => RebuildView(); public ReactiveCommand<RxVoid, RxVoid> InitializeCommand { get; }
partial void OnSelectedSortChanged(SortOption value) => RebuildView(); public ReactiveCommand<RxVoid, RxVoid> ScanCommand { get; }
partial void OnIsScanningChanged(bool value) => OnPropertyChanged(nameof(IsEmpty)); public ReactiveCommand<RxVoid, RxVoid> CancelScanCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> AddFolderCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ToggleThemeCommand { get; }
[Reactive]
public partial string SearchText { get; set; }
[Reactive]
public partial SortOption SelectedSort { get; set; }
[Reactive]
public partial string StatusText { get; set; }
[Reactive]
public partial double ScanProgress { get; set; }
[Reactive]
public partial bool IsProgressIndeterminate { get; set; }
/// <summary>
/// Wires the library cache to the bound collection: debounced search, user-chosen order,
/// and a derived "is anything visible" flag — one subscription for all three.
/// </summary>
private void BuildLibraryView(
out ReadOnlyObservableCollection<VideoCardViewModel> videos,
out ObservableAsPropertyHelper<bool> isEmpty)
{
var filterChanged = this
.WhenAnyValue(x => x.SearchText)
.Throttle(SearchDebounce, TaskPoolScheduler.Default)
// Throttle swallows the initial value, and the grid must not start out blank.
.StartWith(SearchText)
.DistinctUntilChanged()
.Select(BuildFilter);
var comparerChanged = this
.WhenAnyValue(x => x.SelectedSort)
.Select(option => option.Comparer);
isEmpty = _library
.Connect()
// Cards mutate in place while indexing runs; without this their position and
// visibility would be frozen at whatever they were when first inserted.
.AutoRefresh(propertyChangeThrottle: RefreshBuffer, scheduler: TaskPoolScheduler.Default)
.Filter(filterChanged)
.ObserveOn(_uiScheduler)
.SortAndBind(out videos, comparerChanged)
.Count()
.CombineLatest(this.WhenAnyValue(x => x.IsScanning), (count, scanning) => count == 0 && !scanning)
.ToProperty(this, x => x.IsEmpty);
}
private static Func<VideoCardViewModel, bool> BuildFilter(string? term)
{
if (string.IsNullOrWhiteSpace(term))
{
return _ => true;
}
var trimmed = term.Trim();
return card => card.Matches(trimmed);
}
/// <summary>
/// An unobserved <see cref="ReactiveCommand"/> failure is rethrown on the UI thread by
/// the default handler, which takes the process down. Everything funnels here instead.
/// </summary>
private void ObserveCommandFailures() =>
Observable
.Merge(
InitializeCommand.ThrownExceptions,
ScanCommand.ThrownExceptions,
CancelScanCommand.ThrownExceptions,
AddFolderCommand.ThrownExceptions,
ToggleThemeCommand.ThrownExceptions)
.Subscribe(ex =>
{
_logger.LogError(ex, "A command failed");
StatusText = "Что-то пошло не так — подробности в журнале";
})
.AddTo(Subscriptions);
/// <summary>Loads whatever is already in the database, then refreshes it against disk.</summary> /// <summary>Loads whatever is already in the database, then refreshes it against disk.</summary>
[RelayCommand]
private async Task InitializeAsync() private async Task InitializeAsync()
{ {
try try
{ {
await using var scope = _scopeFactory.CreateAsyncScope(); await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>(); var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
var items = await library.GetLibraryAsync();
foreach (var item in await library.GetLibraryAsync()) _library.AddOrUpdate(items.Select(item => new VideoCardViewModel(item, _shell)));
{
var card = new VideoCardViewModel(item, _shell);
_all.Add(card);
_byId[card.Id] = card;
}
} }
catch (Exception ex) catch (Exception ex)
{ {
// An unobserved failure here would tear the process down through the command's // The user can still pick a folder even if the stored library cannot be read.
// task; the user gets a message instead and can still pick a folder.
_logger.LogError(ex, "Could not load the stored library"); _logger.LogError(ex, "Could not load the stored library");
StatusText = "Не удалось открыть базу библиотеки — подробности в журнале"; StatusText = "Не удалось открыть базу библиотеки — подробности в журнале";
return; return;
} }
RebuildView();
if (HasFolders) if (HasFolders)
{ {
await ScanCommand.ExecuteAsync(null); await ScanCommand.Execute();
} }
else else
{ {
@@ -116,7 +222,6 @@ public sealed partial class MainWindowViewModel : ObservableObject
} }
} }
[RelayCommand(IncludeCancelCommand = true)]
private async Task ScanAsync(CancellationToken cancellationToken) private async Task ScanAsync(CancellationToken cancellationToken)
{ {
var folders = _options.CurrentValue.Folders.ToArray(); var folders = _options.CurrentValue.Folders.ToArray();
@@ -127,7 +232,6 @@ public sealed partial class MainWindowViewModel : ObservableObject
return; return;
} }
IsScanning = true;
IsProgressIndeterminate = true; IsProgressIndeterminate = true;
ScanProgress = 0; ScanProgress = 0;
StatusText = "Поиск файлов…"; StatusText = "Поиск файлов…";
@@ -154,20 +258,12 @@ public sealed partial class MainWindowViewModel : ObservableObject
{ {
StatusText = "Сканирование отменено"; StatusText = "Сканирование отменено";
} }
catch (Exception ex)
{
_logger.LogError(ex, "Library scan failed");
StatusText = "Не удалось просканировать библиотеку — подробности в журнале";
}
finally finally
{ {
IsScanning = false;
IsProgressIndeterminate = false; IsProgressIndeterminate = false;
RebuildView();
} }
} }
[RelayCommand]
private async Task AddFolderAsync() private async Task AddFolderAsync()
{ {
var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео"); var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео");
@@ -191,15 +287,11 @@ public sealed partial class MainWindowViewModel : ObservableObject
// sees the folder we just added instead of racing the file watcher. // sees the folder we just added instead of racing the file watcher.
await WaitForFolderAsync(folder); await WaitForFolderAsync(folder);
OnPropertyChanged(nameof(Folders)); this.RaisePropertyChanged(nameof(HasFolders));
OnPropertyChanged(nameof(HasFolders));
await ScanCommand.ExecuteAsync(null); await ScanCommand.Execute();
} }
[RelayCommand]
private void ToggleTheme() => _theme.Toggle();
private async Task WaitForFolderAsync(string folder) private async Task WaitForFolderAsync(string folder)
{ {
for (var attempt = 0; attempt < 20; attempt++) for (var attempt = 0; attempt < 20; attempt++)
@@ -224,24 +316,17 @@ public sealed partial class MainWindowViewModel : ObservableObject
break; break;
case LibraryScanEvent.ItemAdded added: case LibraryScanEvent.ItemAdded added:
{ _library.AddOrUpdate(new VideoCardViewModel(added.Item, _shell));
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; break;
case LibraryScanEvent.ItemRemoved removed case LibraryScanEvent.ItemUpdated updated:
when _byId.Remove(removed.Id, out var dropped): // Mutating the card in place is enough: AutoRefresh turns the resulting
_all.Remove(dropped); // change notification into a re-sort and re-filter of just that one item.
Videos.Remove(dropped); _library.Lookup(updated.Item.Id).IfHasValue(card => card.Apply(updated.Item));
OnPropertyChanged(nameof(IsEmpty)); break;
case LibraryScanEvent.ItemRemoved removed:
_library.RemoveKey(removed.Id);
break; break;
case LibraryScanEvent.IndexingProgress progress: case LibraryScanEvent.IndexingProgress progress:
@@ -261,42 +346,4 @@ public sealed partial class MainWindowViewModel : ObservableObject
break; 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));
}
} }
+38 -14
View File
@@ -1,21 +1,45 @@
using DynamicData.Binding;
namespace PLib.Desktop.ViewModels; namespace PLib.Desktop.ViewModels;
public enum LibrarySort /// <summary>
{ /// A sort order, its label, and the comparer DynamicData keeps the bound collection in.
RecentlyAdded, /// </summary>
TitleAscending, /// <remarks>
LongestFirst, /// Every comparer ends with the identifier so the order is total. Ties would otherwise let
LargestFirst, /// DynamicData move equal items around on each refresh, which the user sees as cards
} /// twitching while a scan streams in.
/// </remarks>
/// <summary>A sort order together with the label the combo box shows for it.</summary> public sealed record SortOption(string Label, IComparer<VideoCardViewModel> Comparer)
public sealed record SortOption(LibrarySort Sort, string Label)
{ {
public static IReadOnlyList<SortOption> All { get; } = public static IReadOnlyList<SortOption> All { get; } =
[ [
new(LibrarySort.RecentlyAdded, "Недавно добавленные"), new("Недавно добавленные", SortExpressionComparer<VideoCardViewModel>
new(LibrarySort.TitleAscending, "По названию"), .Descending(x => x.AddedAt)
new(LibrarySort.LongestFirst, "Сначала длинные"), .ThenByAscending(x => x.Id)),
new(LibrarySort.LargestFirst, "Сначала большие"),
new("По названию", new TitleComparer()),
new("Сначала длинные", SortExpressionComparer<VideoCardViewModel>
.Descending(x => x.RawDuration ?? TimeSpan.Zero)
.ThenByAscending(x => x.Id)),
new("Сначала большие", SortExpressionComparer<VideoCardViewModel>
.Descending(x => x.RawSizeInBytes)
.ThenByAscending(x => x.Id)),
]; ];
/// <summary>
/// Culture-aware, case-insensitive title order. <see cref="SortExpressionComparer{T}"/>
/// would fall back to ordinal comparison, which puts Cyrillic and Latin titles in an
/// order no reader expects.
/// </summary>
private sealed class TitleComparer : IComparer<VideoCardViewModel>
{
public int Compare(VideoCardViewModel? x, VideoCardViewModel? y)
{
var byTitle = string.Compare(x?.Title, y?.Title, StringComparison.CurrentCultureIgnoreCase);
return byTitle != 0 ? byTitle : Comparer<Guid>.Default.Compare(x?.Id ?? Guid.Empty, y?.Id ?? Guid.Empty);
}
}
} }
@@ -1,74 +1,83 @@
using CommunityToolkit.Mvvm.ComponentModel; using PLib.Desktop.Services;
using CommunityToolkit.Mvvm.Input; using PLib.Domain.Videos;
using PLib.Desktop.Services; using ReactiveUI;
using PLib.Domain.Videos; using RxVoid = ReactiveUI.Primitives.RxVoid;
using ReactiveUI.SourceGenerators;
namespace PLib.Desktop.ViewModels;
namespace PLib.Desktop.ViewModels;
/// <summary>One card in the library grid.</summary>
public sealed partial class VideoCardViewModel : ObservableObject /// <summary>One card in the library grid.</summary>
{ /// <remarks>
private readonly ISystemShell _shell; /// Everything the grid sorts or filters by is a reactive property, because DynamicData's
/// <c>AutoRefresh</c> re-evaluates position and visibility off <see cref="ReactiveObject"/>
public VideoCardViewModel(VideoItem item, ISystemShell shell) /// change notifications — a plain auto-property would silently freeze a card in place.
{ /// </remarks>
_shell = shell; public sealed partial class VideoCardViewModel : ReactiveObject
Id = item.Id; {
FullPath = item.FullPath; public VideoCardViewModel(VideoItem item, ISystemShell shell)
Title = item.Title; {
Apply(item); Id = item.Id;
} FullPath = item.FullPath;
Title = item.Title;
public Guid Id { get; }
PlayCommand = ReactiveCommand.Create(() => shell.OpenFile(FullPath));
public string FullPath { get; } RevealCommand = ReactiveCommand.Create(() => shell.RevealInFileManager(FullPath));
public DateTimeOffset AddedAt { get; private set; } Apply(item);
}
public TimeSpan? RawDuration { get; private set; }
public Guid Id { get; }
public long RawSizeInBytes { get; private set; }
public string FullPath { get; }
[ObservableProperty]
public partial string Title { get; set; } public ReactiveCommand<RxVoid, RxVoid> PlayCommand { get; }
[ObservableProperty] public ReactiveCommand<RxVoid, RxVoid> RevealCommand { get; }
public partial string? ThumbnailPath { get; set; }
[Reactive]
[ObservableProperty] public partial string Title { get; set; }
public partial string DurationText { get; set; } = "—";
[Reactive]
[ObservableProperty] public partial string? ThumbnailPath { get; set; }
public partial string SizeText { get; set; } = string.Empty;
[Reactive]
[ObservableProperty] public partial string DurationText { get; set; }
public partial string? QualityText { get; set; }
[Reactive]
/// <summary>True while the poster frame has not been produced yet.</summary> public partial string SizeText { get; set; }
[ObservableProperty]
public partial bool IsPending { get; set; } = true; [Reactive]
public partial string? QualityText { get; set; }
/// <summary>Copies the current state of the entity into the card.</summary>
public void Apply(VideoItem item) /// <summary>True while the poster frame has not been produced yet.</summary>
{ [Reactive]
Title = item.Title; public partial bool IsPending { get; set; }
ThumbnailPath = item.ThumbnailPath;
DurationText = DisplayText.Duration(item.Duration); /// <summary>Raw duration, kept for sorting; <see cref="DurationText"/> is what the card shows.</summary>
SizeText = DisplayText.FileSize(item.SizeInBytes); [Reactive]
QualityText = DisplayText.Quality(item.Width, item.Height); public partial TimeSpan? RawDuration { get; set; }
AddedAt = item.AddedAt;
RawDuration = item.Duration; /// <summary>Raw size, kept for sorting; <see cref="SizeText"/> is what the card shows.</summary>
RawSizeInBytes = item.SizeInBytes; [Reactive]
IsPending = item.ThumbnailPath is null; public partial long RawSizeInBytes { get; set; }
}
public DateTimeOffset AddedAt { get; private set; }
[RelayCommand]
private void Play() => _shell.OpenFile(FullPath); /// <summary>Copies the current state of the entity into the card.</summary>
public void Apply(VideoItem item)
[RelayCommand] {
private void Reveal() => _shell.RevealInFileManager(FullPath); Title = item.Title;
ThumbnailPath = item.ThumbnailPath;
public bool Matches(string term) => DurationText = DisplayText.Duration(item.Duration);
Title.Contains(term, StringComparison.CurrentCultureIgnoreCase) || SizeText = DisplayText.FileSize(item.SizeInBytes);
FullPath.Contains(term, StringComparison.CurrentCultureIgnoreCase); QualityText = DisplayText.Quality(item.Width, item.Height);
} AddedAt = item.AddedAt;
RawDuration = item.Duration;
RawSizeInBytes = item.SizeInBytes;
IsPending = item.ThumbnailPath is null;
}
public bool Matches(string term) =>
Title.Contains(term, StringComparison.CurrentCultureIgnoreCase) ||
FullPath.Contains(term, StringComparison.CurrentCultureIgnoreCase);
}
@@ -0,0 +1,28 @@
using System.Reactive.Disposables;
using ReactiveUI;
namespace PLib.Desktop.ViewModels;
/// <summary>
/// Common base for every view model: change notification from <see cref="ReactiveObject"/>
/// plus a bag to park subscriptions in, so nothing outlives the view model that made it.
/// </summary>
public abstract class ViewModelBase : ReactiveObject, IDisposable
{
private bool _disposed;
/// <summary>Subscriptions torn down together with this view model.</summary>
protected CompositeDisposable Subscriptions { get; } = [];
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
Subscriptions.Dispose();
GC.SuppressFinalize(this);
}
}
+1 -1
View File
@@ -224,7 +224,7 @@
<Button Grid.Column="2" <Button Grid.Column="2"
Content="Отмена" Content="Отмена"
Command="{Binding ScanCancelCommand}" Command="{Binding CancelScanCommand}"
IsVisible="{Binding IsScanning}" /> IsVisible="{Binding IsScanning}" />
</Grid> </Grid>
+3 -2
View File
@@ -1,8 +1,9 @@
using Avalonia.Controls; using ReactiveUI.Avalonia;
using PLib.Desktop.ViewModels;
namespace PLib.Desktop.Views; namespace PLib.Desktop.Views;
public sealed partial class MainWindow : Window public sealed partial class MainWindow : ReactiveWindow<MainWindowViewModel>
{ {
public MainWindow() => InitializeComponent(); public MainWindow() => InitializeComponent();
} }
@@ -18,6 +18,15 @@ public sealed class FfmpegThumbnailGenerator(
/// <summary>Fallback capture position for files whose duration we could not read.</summary> /// <summary>Fallback capture position for files whose duration we could not read.</summary>
private static readonly TimeSpan BlindCapturePosition = TimeSpan.FromSeconds(5); private static readonly TimeSpan BlindCapturePosition = TimeSpan.FromSeconds(5);
/// <summary>
/// How long an unfinished render is left alone before it counts as abandoned. A second
/// instance of the application could be mid-render right now, and deleting its staging
/// file would silently cost it the frame.
/// </summary>
private static readonly TimeSpan AbandonedRenderAge = TimeSpan.FromHours(1);
private const string StagingExtension = ".tmp";
private readonly LibraryOptions _options = options.Value; private readonly LibraryOptions _options = options.Value;
public async Task<string?> GetOrCreateAsync( public async Task<string?> GetOrCreateAsync(
@@ -41,7 +50,7 @@ public sealed class FfmpegThumbnailGenerator(
// Render to a private temp file first so a crash or cancellation can never leave a // 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. // truncated JPEG behind that later runs would happily treat as a valid cache hit.
var staging = Path.Combine(paths.ThumbnailDirectory, $"{Guid.CreateVersion7()}.tmp"); var staging = Path.Combine(paths.ThumbnailDirectory, $"{Guid.CreateVersion7()}{StagingExtension}");
try try
{ {
@@ -73,6 +82,58 @@ public sealed class FfmpegThumbnailGenerator(
} }
} }
public bool IsAvailable(string? thumbnailPath) =>
!string.IsNullOrEmpty(thumbnailPath) && File.Exists(thumbnailPath);
public Task<int> PurgeUnusedAsync(
IReadOnlyCollection<string> inUsePaths,
CancellationToken cancellationToken = default) =>
Task.Run(() => Purge(inUsePaths, cancellationToken), cancellationToken);
private int Purge(IReadOnlyCollection<string> inUsePaths, CancellationToken cancellationToken)
{
if (!Directory.Exists(paths.ThumbnailDirectory))
{
return 0;
}
var inUse = new HashSet<string>(inUsePaths, LibraryPathComparer.Instance);
var removed = 0;
foreach (var file in Directory.EnumerateFiles(paths.ThumbnailDirectory))
{
cancellationToken.ThrowIfCancellationRequested();
if (!ShouldRemove(file, inUse))
{
continue;
}
try
{
File.Delete(file);
removed++;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Somebody else is holding the file; the next scan will try again.
logger.LogDebug(ex, "Could not remove the cached frame {Path}", file);
}
}
return removed;
}
private bool ShouldRemove(string file, HashSet<string> inUse)
{
if (file.EndsWith(StagingExtension, StringComparison.OrdinalIgnoreCase))
{
return File.GetLastWriteTimeUtc(file) < DateTime.UtcNow - AbandonedRenderAge;
}
return !inUse.Contains(file);
}
private async Task<bool> RenderAsync( private async Task<bool> RenderAsync(
string videoPath, string videoPath,
string outputPath, string outputPath,
@@ -24,6 +24,9 @@ public sealed class LibraryServiceTests
_thumbnails.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>()) _thumbnails.GetOrCreateAsync(Arg.Any<string>(), Arg.Any<TimeSpan?>(), Arg.Any<CancellationToken>())
.Returns(callInfo => $@"C:\cache\{Path.GetFileNameWithoutExtension(callInfo.Arg<string>())}.jpg"); .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] [Fact]
@@ -94,6 +97,36 @@ public sealed class LibraryServiceTests
_repository.Items.Single().ThumbnailPath.ShouldBe(@"C:\cache\a.jpg"); _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] [Fact]
public async Task The_same_file_reached_through_two_overlapping_roots_is_only_added_once() public async Task The_same_file_reached_through_two_overlapping_roots_is_only_added_once()
{ {