diff --git a/Directory.Packages.props b/Directory.Packages.props
index a6f6b11..ed107ea 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -46,6 +46,7 @@
+
diff --git a/README.md b/README.md
index d99b750..3a46865 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,9 @@
- Метаданные (длительность, разрешение, кодек) через ffprobe.
- Постеры кадром из видео через ffmpeg, с кэшем на диске.
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
-- Светлая и тёмная темы.
+- Окно настроек: папки библиотеки (с удалением), параметры превью и сканирования, тема,
+ очистка кэша превью. Всё пишется в `settings.json` и подхватывается без перезапуска.
+- Светлая, тёмная и системная темы; выбор запоминается.
- Клик или Enter по карточке — открыть в системном плеере, правая кнопка — контекстное меню.
## Требования
@@ -60,6 +62,10 @@ dotnet test
декодированием в нужную ширину. Память зависит от размера окна, а не от размера библиотеки.
- **Scope на операцию.** `DbContext` живёт ровно одну операцию — ViewModel берёт
`IServiceScopeFactory` и создаёт scope на каждый вызов.
+- **Настройки — рабочая копия.** Диалог правит снимок `AppSettings` и записывает его целиком
+ только по «Сохранить», так что отмена не оставляет следов. Пересканирование запускается
+ только если изменилось то, что влияет на состав библиотеки, — смена темы или ширины кадра
+ его не вызывает.
- **Кэш превью самовосстанавливается.** Диск — ключ `sha256(путь|размер|mtime)`, память — LRU
на 256 декодированных битмапов. Сканирование проверяет, что запомненный кадр физически
на месте (`IThumbnailGenerator.IsAvailable`), и перерисовывает удалённые; после полного
diff --git a/src/PLib.Application/Abstractions/IThumbnailGenerator.cs b/src/PLib.Application/Abstractions/IThumbnailGenerator.cs
index 6f81755..ba3c5f2 100644
--- a/src/PLib.Application/Abstractions/IThumbnailGenerator.cs
+++ b/src/PLib.Application/Abstractions/IThumbnailGenerator.cs
@@ -1,31 +1,37 @@
-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);
-
- ///
- /// 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.
- ///
- bool IsAvailable(string? thumbnailPath);
-
- ///
- /// 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
- /// is treated as garbage.
- ///
- Task PurgeUnusedAsync(
- IReadOnlyCollection inUsePaths,
- CancellationToken cancellationToken = default);
-}
+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);
+
+ ///
+ /// 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.
+ ///
+ bool IsAvailable(string? thumbnailPath);
+
+ ///
+ /// 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
+ /// is treated as garbage.
+ ///
+ Task PurgeUnusedAsync(
+ IReadOnlyCollection inUsePaths,
+ CancellationToken cancellationToken = default);
+
+ /// How much disk the cache currently occupies, in bytes.
+ Task GetCacheSizeInBytesAsync(CancellationToken cancellationToken = default);
+
+ /// Empties the cache completely. Returns how many files were removed.
+ Task ClearAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Application/Library/ILibraryService.cs b/src/PLib.Application/Library/ILibraryService.cs
index 6b3ed4c..242b6b2 100644
--- a/src/PLib.Application/Library/ILibraryService.cs
+++ b/src/PLib.Application/Library/ILibraryService.cs
@@ -1,18 +1,27 @@
-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);
-}
+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);
+
+ /// Disk space currently taken by cached poster frames, in bytes.
+ Task GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Throws every poster frame away and forgets the paths, so the next scan renders them
+ /// from scratch. Useful after changing the thumbnail width or capture position.
+ ///
+ Task ResetThumbnailsAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Application/Library/LibraryService.cs b/src/PLib.Application/Library/LibraryService.cs
index 88d32ca..2a821d7 100644
--- a/src/PLib.Application/Library/LibraryService.cs
+++ b/src/PLib.Application/Library/LibraryService.cs
@@ -27,6 +27,27 @@ public sealed class LibraryService(
return [.. items.OrderByDescending(x => x.AddedAt)];
}
+ public Task GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default) =>
+ thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken);
+
+ public async Task ResetThumbnailsAsync(CancellationToken cancellationToken = default)
+ {
+ var items = await repository.GetAllAsync(cancellationToken);
+
+ foreach (var item in items)
+ {
+ item.DetachThumbnail();
+ }
+
+ // Forget the paths before deleting the files. Interrupted the other way round, the
+ // library would point at frames that no longer exist — recoverable, but only after
+ // a full scan notices. This order leaves at worst some orphans, which the purge eats.
+ await repository.SaveChangesAsync(cancellationToken);
+
+ var removed = await thumbnailGenerator.ClearAsync(cancellationToken);
+ logger.LogInformation("Cleared {Count} cached poster frames on request", removed);
+ }
+
public async IAsyncEnumerable ScanAsync(
IReadOnlyList folders,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
diff --git a/src/PLib.Desktop/App.axaml.cs b/src/PLib.Desktop/App.axaml.cs
index 4bb411b..6c1f9a0 100644
--- a/src/PLib.Desktop/App.axaml.cs
+++ b/src/PLib.Desktop/App.axaml.cs
@@ -4,8 +4,11 @@ using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
using PLib.Desktop.Controls;
using PLib.Desktop.Imaging;
+using PLib.Desktop.Services;
+using PLib.Desktop.Settings;
using PLib.Desktop.ViewModels;
using PLib.Desktop.Views;
@@ -27,6 +30,11 @@ public sealed class App : Avalonia.Application
AsyncImage.Loader = _host.Services.GetRequiredService();
+ // The theme lives in configuration, so restore the user's choice before the
+ // first window is shown and avoid a visible flash of the wrong variant.
+ _host.Services.GetRequiredService().Apply(
+ _host.Services.GetRequiredService>().CurrentValue.Theme);
+
var viewModel = _host.Services.GetRequiredService();
desktop.MainWindow = new MainWindow { DataContext = viewModel };
desktop.Exit += OnExit;
diff --git a/src/PLib.Desktop/AppHost.cs b/src/PLib.Desktop/AppHost.cs
index b871cf7..4211cf8 100644
--- a/src/PLib.Desktop/AppHost.cs
+++ b/src/PLib.Desktop/AppHost.cs
@@ -1,69 +1,78 @@
-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);
- }
-}
+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.Settings;
+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.AddOptions()
+ .Bind(builder.Configuration.GetSection(AppearanceOptions.SectionName));
+ 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();
+ builder.Services.AddSingleton();
+
+ // Scoped, not singleton: the settings dialog edits a working copy, so each opening
+ // gets its own model and a cancelled edit never leaks into the next one.
+ builder.Services.AddScoped();
+
+ 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/Services/DialogService.cs b/src/PLib.Desktop/Services/DialogService.cs
new file mode 100644
index 0000000..9e51b83
--- /dev/null
+++ b/src/PLib.Desktop/Services/DialogService.cs
@@ -0,0 +1,42 @@
+using System.Reactive.Linq;
+using Avalonia.Controls.ApplicationLifetimes;
+using Microsoft.Extensions.DependencyInjection;
+using PLib.Desktop.ViewModels;
+using PLib.Desktop.Views;
+
+namespace PLib.Desktop.Services;
+
+/// Opens the application's dialogs, so view models never touch window types.
+public interface IDialogService
+{
+ Task ShowSettingsAsync();
+}
+
+///
+public sealed class DialogService(IServiceScopeFactory scopeFactory) : IDialogService
+{
+ public async Task ShowSettingsAsync()
+ {
+ if (Avalonia.Application.Current?.ApplicationLifetime
+ is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
+ {
+ return SettingsDialogOutcome.Cancelled;
+ }
+
+ // A scope per dialog: the container would otherwise keep every settings view model
+ // it ever built alive until shutdown, and disposing the scope disposes the model.
+ await using var scope = scopeFactory.CreateAsyncScope();
+ var viewModel = scope.ServiceProvider.GetRequiredService();
+
+ var window = new SettingsWindow { DataContext = viewModel };
+
+ // The view model decides when it is done; the window only carries the answer out.
+ using var subscription = viewModel.Closed.Subscribe(outcome => window.Close(outcome));
+
+ // Show a real number in the cache section rather than an empty label.
+ await viewModel.RefreshCacheSizeCommand.Execute().FirstAsync();
+
+ // Closing through the title bar yields null, which counts as cancelling.
+ return await window.ShowDialog(owner) ?? SettingsDialogOutcome.Cancelled;
+ }
+}
diff --git a/src/PLib.Desktop/Services/IAppSettingsStore.cs b/src/PLib.Desktop/Services/IAppSettingsStore.cs
new file mode 100644
index 0000000..2a32dbf
--- /dev/null
+++ b/src/PLib.Desktop/Services/IAppSettingsStore.cs
@@ -0,0 +1,12 @@
+using PLib.Desktop.Settings;
+
+namespace PLib.Desktop.Services;
+
+///
+/// Persists the settings the user can change at runtime. The file it writes is also a
+/// configuration source, so IOptionsMonitor picks changes up without a restart.
+///
+public interface IAppSettingsStore
+{
+ Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default);
+}
diff --git a/src/PLib.Desktop/Services/ILibrarySettingsStore.cs b/src/PLib.Desktop/Services/ILibrarySettingsStore.cs
deleted file mode 100644
index fb82dc4..0000000
--- a/src/PLib.Desktop/Services/ILibrarySettingsStore.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-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/JsonAppSettingsStore.cs
similarity index 53%
rename from src/PLib.Desktop/Services/JsonLibrarySettingsStore.cs
rename to src/PLib.Desktop/Services/JsonAppSettingsStore.cs
index a3c5dad..8292bbd 100644
--- a/src/PLib.Desktop/Services/JsonLibrarySettingsStore.cs
+++ b/src/PLib.Desktop/Services/JsonAppSettingsStore.cs
@@ -1,12 +1,13 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using PLib.Application.Library;
+using PLib.Desktop.Settings;
using PLib.Infrastructure.Storage;
namespace PLib.Desktop.Services;
-///
-public sealed class JsonLibrarySettingsStore(IAppPaths paths) : ILibrarySettingsStore
+///
+public sealed class JsonAppSettingsStore(IAppPaths paths) : IAppSettingsStore
{
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
@@ -14,23 +15,24 @@ public sealed class JsonLibrarySettingsStore(IAppPaths paths) : ILibrarySettings
private string SettingsFile => Path.Combine(paths.DataDirectory, "settings.json");
- public async Task SaveFoldersAsync(
- IReadOnlyList folders,
- CancellationToken cancellationToken = default)
+ public async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default)
{
await _writeLock.WaitAsync(cancellationToken);
try
{
+ // Merge into whatever is already there: the file is hand-editable and may hold
+ // keys this version of the settings screen knows nothing about.
var root = await ReadRootAsync(cancellationToken);
- if (root[LibraryOptions.SectionName] is not JsonObject section)
- {
- section = [];
- root[LibraryOptions.SectionName] = section;
- }
+ var library = Section(root, LibraryOptions.SectionName);
+ library["Folders"] = new JsonArray([.. settings.Folders.Select(folder => (JsonNode)JsonValue.Create(folder))]);
+ library["ThumbnailWidth"] = settings.ThumbnailWidth;
+ library["ThumbnailPositionRatio"] = settings.ThumbnailPositionRatio;
+ library["MaxIndexingConcurrency"] = settings.MaxIndexingConcurrency;
+ library["MinimumFileSizeInBytes"] = settings.MinimumFileSizeInBytes;
- section["Folders"] = new JsonArray([.. folders.Select(f => (JsonNode)JsonValue.Create(f))]);
+ Section(root, AppearanceOptions.SectionName)["Theme"] = settings.Theme.ToString();
// Write through a temp file so an interrupted save cannot corrupt the settings.
var staging = SettingsFile + ".tmp";
@@ -43,6 +45,18 @@ public sealed class JsonLibrarySettingsStore(IAppPaths paths) : ILibrarySettings
}
}
+ private static JsonObject Section(JsonObject root, string name)
+ {
+ if (root[name] is JsonObject existing)
+ {
+ return existing;
+ }
+
+ var created = new JsonObject();
+ root[name] = created;
+ return created;
+ }
+
private async Task ReadRootAsync(CancellationToken cancellationToken)
{
if (!File.Exists(SettingsFile))
diff --git a/src/PLib.Desktop/Services/ThemeService.cs b/src/PLib.Desktop/Services/ThemeService.cs
index a66945d..699cbfd 100644
--- a/src/PLib.Desktop/Services/ThemeService.cs
+++ b/src/PLib.Desktop/Services/ThemeService.cs
@@ -1,28 +1,48 @@
-using Avalonia;
using Avalonia.Styling;
+using PLib.Desktop.Settings;
namespace PLib.Desktop.Services;
-/// Switches the application between the light and dark variants.
+/// Applies the light/dark/system choice to the running application.
public interface IThemeService
{
- void Toggle();
+ /// The mode currently in effect.
+ ThemeMode Current { get; }
+
+ void Apply(ThemeMode mode);
+
+ /// Flips between light and dark, resolving "system" to whatever is on screen.
+ ThemeMode Toggle();
}
///
public sealed class ThemeService : IThemeService
{
- public void Toggle()
- {
- if (Avalonia.Application.Current is not { } application)
- {
- return;
- }
+ public ThemeMode Current { get; private set; } = ThemeMode.System;
- // 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;
+ public void Apply(ThemeMode mode)
+ {
+ Current = mode;
+
+ if (Avalonia.Application.Current is { } application)
+ {
+ application.RequestedThemeVariant = mode switch
+ {
+ ThemeMode.Light => ThemeVariant.Light,
+ ThemeMode.Dark => ThemeVariant.Dark,
+ _ => ThemeVariant.Default,
+ };
+ }
+ }
+
+ public ThemeMode Toggle()
+ {
+ // ActualThemeVariant resolves "follow the system" to what the user is actually
+ // looking at, which is what they mean by "the other one".
+ var isDark = Avalonia.Application.Current?.ActualThemeVariant == ThemeVariant.Dark;
+ var next = isDark ? ThemeMode.Light : ThemeMode.Dark;
+
+ Apply(next);
+ return next;
}
}
diff --git a/src/PLib.Desktop/Settings/AppSettings.cs b/src/PLib.Desktop/Settings/AppSettings.cs
new file mode 100644
index 0000000..41de518
--- /dev/null
+++ b/src/PLib.Desktop/Settings/AppSettings.cs
@@ -0,0 +1,43 @@
+using PLib.Application.Library;
+
+namespace PLib.Desktop.Settings;
+
+///
+/// The subset of configuration the user can change at runtime, as one snapshot.
+///
+///
+/// Everything is written in a single pass rather than key by key: a settings file that is
+/// only ever replaced whole cannot end up in a state that never existed in the UI.
+///
+public sealed record AppSettings
+{
+ public required IReadOnlyList Folders { get; init; }
+
+ public required int ThumbnailWidth { get; init; }
+
+ public required double ThumbnailPositionRatio { get; init; }
+
+ public required int MaxIndexingConcurrency { get; init; }
+
+ public required long MinimumFileSizeInBytes { get; init; }
+
+ public required ThemeMode Theme { get; init; }
+
+ public static AppSettings From(LibraryOptions library, AppearanceOptions appearance) => new()
+ {
+ Folders = [.. library.Folders],
+ ThumbnailWidth = library.ThumbnailWidth,
+ ThumbnailPositionRatio = library.ThumbnailPositionRatio,
+ MaxIndexingConcurrency = library.MaxIndexingConcurrency,
+ MinimumFileSizeInBytes = library.MinimumFileSizeInBytes,
+ Theme = appearance.Theme,
+ };
+
+ ///
+ /// True when the difference between the two snapshots means the library has to be
+ /// walked again. Cosmetic changes must not trigger a rescan.
+ ///
+ public bool RequiresRescanComparedTo(AppSettings other) =>
+ !Folders.SequenceEqual(other.Folders, LibraryPathComparer.Instance) ||
+ MinimumFileSizeInBytes != other.MinimumFileSizeInBytes;
+}
diff --git a/src/PLib.Desktop/Settings/AppearanceOptions.cs b/src/PLib.Desktop/Settings/AppearanceOptions.cs
new file mode 100644
index 0000000..7b1a43f
--- /dev/null
+++ b/src/PLib.Desktop/Settings/AppearanceOptions.cs
@@ -0,0 +1,17 @@
+namespace PLib.Desktop.Settings;
+
+public enum ThemeMode
+{
+ /// Follow whatever the operating system is set to.
+ System,
+ Light,
+ Dark,
+}
+
+/// Look-and-feel settings; purely a concern of the desktop shell.
+public sealed class AppearanceOptions
+{
+ public const string SectionName = "Appearance";
+
+ public ThemeMode Theme { get; init; } = ThemeMode.Dark;
+}
diff --git a/src/PLib.Desktop/ViewModels/FolderEntryViewModel.cs b/src/PLib.Desktop/ViewModels/FolderEntryViewModel.cs
new file mode 100644
index 0000000..f1a052b
--- /dev/null
+++ b/src/PLib.Desktop/ViewModels/FolderEntryViewModel.cs
@@ -0,0 +1,21 @@
+using ReactiveUI;
+using RxVoid = ReactiveUI.Primitives.RxVoid;
+
+namespace PLib.Desktop.ViewModels;
+
+///
+/// One library folder in the settings list. It carries its own remove command so the row
+/// template never has to reach up the visual tree for the parent view model.
+///
+public sealed class FolderEntryViewModel
+{
+ public FolderEntryViewModel(string path, Action remove)
+ {
+ Path = path;
+ RemoveCommand = ReactiveCommand.Create(() => remove(this));
+ }
+
+ public string Path { get; }
+
+ public ReactiveCommand RemoveCommand { get; }
+}
diff --git a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs
index 7179823..3a33cb2 100644
--- a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs
+++ b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs
@@ -4,12 +4,14 @@ using System.Reactive.Linq;
using System.Reactive.Subjects;
using Avalonia.Threading;
using DynamicData;
+using DynamicData.Aggregation;
using DynamicData.Kernel;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PLib.Application.Library;
using PLib.Desktop.Services;
+using PLib.Desktop.Settings;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
// Type alias, not a namespace import: pulling in ReactiveUI.Primitives would put a second
@@ -31,7 +33,10 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptionsMonitor _options;
- private readonly ILibrarySettingsStore _settingsStore;
+ private readonly IAppSettingsStore _settingsStore;
+ private readonly IOptionsMonitor _appearance;
+ private readonly IDialogService _dialogs;
+ private readonly IThemeService _theme;
private readonly IFolderPicker _folderPicker;
private readonly ISystemShell _shell;
private readonly ILogger _logger;
@@ -59,7 +64,9 @@ public sealed partial class MainWindowViewModel : ViewModelBase
public MainWindowViewModel(
IServiceScopeFactory scopeFactory,
IOptionsMonitor options,
- ILibrarySettingsStore settingsStore,
+ IOptionsMonitor appearance,
+ IAppSettingsStore settingsStore,
+ IDialogService dialogs,
IFolderPicker folderPicker,
ISystemShell shell,
IThemeService theme,
@@ -67,7 +74,10 @@ public sealed partial class MainWindowViewModel : ViewModelBase
{
_scopeFactory = scopeFactory;
_options = options;
+ _appearance = appearance;
_settingsStore = settingsStore;
+ _dialogs = dialogs;
+ _theme = theme;
_folderPicker = folderPicker;
_shell = shell;
_logger = logger;
@@ -76,7 +86,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
InitializeCommand = ReactiveCommand.CreateFromTask(InitializeAsync);
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
- ToggleThemeCommand = ReactiveCommand.Create(theme.Toggle);
+ ToggleThemeCommand = ReactiveCommand.CreateFromTask(ToggleThemeAsync);
+ OpenSettingsCommand = ReactiveCommand.CreateFromTask(OpenSettingsAsync);
// Cancellation the ReactiveUI way: the scan runs as an observable, and cancelling
// simply unsubscribes it, which cancels the token Observable.StartAsync handed out.
@@ -115,6 +126,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
public ReactiveCommand ToggleThemeCommand { get; }
+ public ReactiveCommand OpenSettingsCommand { get; }
+
[Reactive]
public partial string SearchText { get; set; }
@@ -150,16 +163,21 @@ public sealed partial class MainWindowViewModel : ViewModelBase
.WhenAnyValue(x => x.SelectedSort)
.Select(option => option.Comparer);
- isEmpty = _library
+ var bound = _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)
+ .SortAndBind(out videos, comparerChanged);
+
+ // Called as a static on purpose. Written as an extension method, System.Reactive's
+ // Count() also matches and wins — and that one only emits when the source completes,
+ // which a change set never does, so the flag would silently stay false forever.
+ isEmpty = CountEx
+ .IsEmpty(bound)
+ .CombineLatest(this.WhenAnyValue(x => x.IsScanning), (empty, scanning) => empty && !scanning)
.ToProperty(this, x => x.IsEmpty);
}
@@ -185,7 +203,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
ScanCommand.ThrownExceptions,
CancelScanCommand.ThrownExceptions,
AddFolderCommand.ThrownExceptions,
- ToggleThemeCommand.ThrownExceptions)
+ ToggleThemeCommand.ThrownExceptions,
+ OpenSettingsCommand.ThrownExceptions)
.Subscribe(ex =>
{
_logger.LogError(ex, "A command failed");
@@ -226,12 +245,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase
{
var folders = _options.CurrentValue.Folders.ToArray();
- if (folders.Length == 0)
- {
- StatusText = "Не выбрано ни одной папки";
- return;
- }
-
IsProgressIndeterminate = true;
ScanProgress = 0;
StatusText = "Поиск файлов…";
@@ -261,6 +274,13 @@ public sealed partial class MainWindowViewModel : ViewModelBase
finally
{
IsProgressIndeterminate = false;
+
+ if (folders.Length == 0)
+ {
+ // The reconciliation still had to run — it is what empties the grid after the
+ // last folder is removed — but "nothing found" would be the wrong explanation.
+ StatusText = "Не выбрано ни одной папки";
+ }
}
}
@@ -281,22 +301,59 @@ public sealed partial class MainWindowViewModel : ViewModelBase
}
folders.Add(folder);
- await _settingsStore.SaveFoldersAsync(folders);
+ await 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);
+ await WaitForConfigurationAsync(
+ () => _options.CurrentValue.Folders.Contains(folder, LibraryPathComparer.Instance));
this.RaisePropertyChanged(nameof(HasFolders));
await ScanCommand.Execute();
}
- private async Task WaitForFolderAsync(string folder)
+ private async Task OpenSettingsAsync()
+ {
+ var outcome = await _dialogs.ShowSettingsAsync();
+
+ if (outcome is not { Saved: true, Settings: { } saved })
+ {
+ return;
+ }
+
+ // The dialog wrote the file; wait for the configuration to catch up before anything
+ // reads it back, otherwise the rescan below would use the previous folder list.
+ await WaitForConfigurationAsync(
+ () => _options.CurrentValue.Folders.SequenceEqual(saved.Folders, LibraryPathComparer.Instance));
+
+ this.RaisePropertyChanged(nameof(HasFolders));
+
+ if (outcome.RescanRequired)
+ {
+ await ScanCommand.Execute();
+ }
+ }
+
+ private async Task ToggleThemeAsync()
+ {
+ var mode = _theme.Toggle();
+ await _settingsStore.SaveAsync(CurrentSettings with { Theme = mode });
+ }
+
+ private AppSettings CurrentSettings => AppSettings.From(_options.CurrentValue, _appearance.CurrentValue);
+
+ private Task SaveFoldersAsync(IReadOnlyList folders) =>
+ _settingsStore.SaveAsync(CurrentSettings with { Folders = folders });
+
+ ///
+ /// A saved settings file is not visible through IOptionsMonitor straight away —
+ /// the reload runs off a file watcher. Give it a moment before reading the value back,
+ /// otherwise a rescan would still be looking at the previous configuration.
+ ///
+ private async Task WaitForConfigurationAsync(Func isUpToDate)
{
for (var attempt = 0; attempt < 20; attempt++)
{
- if (_options.CurrentValue.Folders.Contains(folder, LibraryPathComparer.Instance))
+ if (isUpToDate())
{
return;
}
@@ -304,7 +361,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
await Task.Delay(50);
}
- _logger.LogWarning("Configuration did not pick up the new folder {Folder} in time", folder);
+ _logger.LogWarning("Configuration did not reload in time after saving settings");
}
private void Handle(LibraryScanEvent scanEvent)
diff --git a/src/PLib.Desktop/ViewModels/SettingsViewModel.cs b/src/PLib.Desktop/ViewModels/SettingsViewModel.cs
new file mode 100644
index 0000000..a085db6
--- /dev/null
+++ b/src/PLib.Desktop/ViewModels/SettingsViewModel.cs
@@ -0,0 +1,231 @@
+using System.Collections.ObjectModel;
+using System.Reactive.Disposables;
+using System.Reactive.Linq;
+using System.Reactive.Subjects;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using PLib.Application.Library;
+using PLib.Desktop.Services;
+using PLib.Desktop.Settings;
+using ReactiveUI;
+using ReactiveUI.SourceGenerators;
+using RxVoid = ReactiveUI.Primitives.RxVoid;
+
+namespace PLib.Desktop.ViewModels;
+
+///
+/// Edits a working copy of the settings and only writes it back when the user confirms, so
+/// cancelling leaves both the file and the running configuration untouched.
+///
+public sealed partial class SettingsViewModel : ViewModelBase
+{
+ private const double BytesPerMegabyte = 1024 * 1024;
+
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly IAppSettingsStore _settingsStore;
+ private readonly IFolderPicker _folderPicker;
+ private readonly IThemeService _theme;
+ private readonly ILogger _logger;
+
+ /// The state the dialog opened with; the baseline every change is compared to.
+ private readonly AppSettings _original;
+
+ private readonly Subject _closed = new();
+
+ /// Set once the cache has been wiped, which always forces a rescan on close.
+ private bool _thumbnailsWereReset;
+
+ public SettingsViewModel(
+ IServiceScopeFactory scopeFactory,
+ IOptionsMonitor library,
+ IOptionsMonitor appearance,
+ IAppSettingsStore settingsStore,
+ IFolderPicker folderPicker,
+ IThemeService theme,
+ ILogger logger)
+ {
+ _scopeFactory = scopeFactory;
+ _settingsStore = settingsStore;
+ _folderPicker = folderPicker;
+ _theme = theme;
+ _logger = logger;
+
+ // CurrentValue, not IOptions.Value: the dialog can be reopened after a save, and a
+ // cached snapshot would show the settings the application started with.
+ _original = AppSettings.From(library.CurrentValue, appearance.CurrentValue);
+
+ Folders = [.. _original.Folders.Select(CreateEntry)];
+ ThumbnailWidth = _original.ThumbnailWidth;
+ ThumbnailPositionPercent = Math.Round(_original.ThumbnailPositionRatio * 100);
+ MaxIndexingConcurrency = _original.MaxIndexingConcurrency;
+ MinimumFileSizeMegabytes = Math.Round(_original.MinimumFileSizeInBytes / BytesPerMegabyte, 2);
+ SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
+
+ AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
+ RefreshCacheSizeCommand = ReactiveCommand.CreateFromTask(RefreshCacheSizeAsync);
+ ClearCacheCommand = ReactiveCommand.CreateFromTask(ClearCacheAsync);
+ SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync);
+ CancelCommand = ReactiveCommand.Create(() => _closed.OnNext(SettingsDialogOutcome.Cancelled));
+
+ // A binding cannot negate an int, so the "nothing added yet" hint reads a bool that
+ // is re-raised whenever the list changes.
+ Folders.CollectionChanged += OnFoldersChanged;
+ Disposable
+ .Create(() => Folders.CollectionChanged -= OnFoldersChanged)
+ .AddTo(Subscriptions);
+
+ ObserveCommandFailures();
+ }
+
+ /// Fires once, when the dialog should close.
+ public IObservable Closed => _closed;
+
+ public ObservableCollection Folders { get; }
+
+ public bool HasFolders => Folders.Count > 0;
+
+ public IReadOnlyList ThemeOptions { get; } = ThemeOption.All;
+
+ public ReactiveCommand AddFolderCommand { get; }
+
+ public ReactiveCommand RefreshCacheSizeCommand { get; }
+
+ public ReactiveCommand ClearCacheCommand { get; }
+
+ public ReactiveCommand SaveCommand { get; }
+
+ public ReactiveCommand CancelCommand { get; }
+
+ /// Width of generated poster frames; height follows the source aspect ratio.
+ [Reactive]
+ public partial int ThumbnailWidth { get; set; }
+
+ /// How far into the video the poster frame is taken, in percent.
+ [Reactive]
+ public partial double ThumbnailPositionPercent { get; set; }
+
+ [Reactive]
+ public partial int MaxIndexingConcurrency { get; set; }
+
+ [Reactive]
+ public partial double MinimumFileSizeMegabytes { get; set; }
+
+ [Reactive]
+ public partial ThemeOption SelectedTheme { get; set; }
+
+ [Reactive]
+ public partial string CacheSizeText { get; set; }
+
+ [Reactive]
+ public partial bool IsBusy { get; set; }
+
+ [Reactive]
+ public partial string? Message { get; set; }
+
+ private AppSettings CurrentDraft => new()
+ {
+ Folders = [.. Folders.Select(entry => entry.Path)],
+ ThumbnailWidth = ThumbnailWidth,
+ ThumbnailPositionRatio = Math.Round(ThumbnailPositionPercent / 100, 4),
+ MaxIndexingConcurrency = MaxIndexingConcurrency,
+ MinimumFileSizeInBytes = (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte),
+ Theme = SelectedTheme.Mode,
+ };
+
+ private async Task AddFolderAsync()
+ {
+ var folder = await _folderPicker.PickFolderAsync("Выберите папку с видео");
+
+ if (folder is null || Folders.Any(entry => LibraryPathComparer.Instance.Equals(entry.Path, folder)))
+ {
+ return;
+ }
+
+ Folders.Add(CreateEntry(folder));
+ }
+
+ private void OnFoldersChanged(object? sender, EventArgs e) => this.RaisePropertyChanged(nameof(HasFolders));
+
+ private FolderEntryViewModel CreateEntry(string path) =>
+ new(path, entry => Folders.Remove(entry));
+
+ private async Task RefreshCacheSizeAsync()
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var library = scope.ServiceProvider.GetRequiredService();
+
+ var bytes = await library.GetThumbnailCacheSizeAsync();
+ CacheSizeText = DisplayText.FileSize(bytes);
+ }
+
+ private async Task ClearCacheAsync()
+ {
+ IsBusy = true;
+
+ try
+ {
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var library = scope.ServiceProvider.GetRequiredService();
+
+ await library.ResetThumbnailsAsync();
+ await RefreshCacheSizeAsync();
+
+ // The frames are gone from disk and from the library, so the grid has to be
+ // rebuilt regardless of what else the user changes before closing.
+ _thumbnailsWereReset = true;
+ Message = "Кэш очищен — превью соберутся заново при следующем сканировании";
+ }
+ finally
+ {
+ IsBusy = false;
+ }
+ }
+
+ private async Task SaveAsync()
+ {
+ var draft = CurrentDraft;
+
+ await _settingsStore.SaveAsync(draft);
+
+ // The theme is not read from configuration again while the app runs, so apply it here.
+ _theme.Apply(draft.Theme);
+
+ var rescan = _thumbnailsWereReset || draft.RequiresRescanComparedTo(_original);
+ _closed.OnNext(new SettingsDialogOutcome(Saved: true, rescan, draft));
+ }
+
+ private void ObserveCommandFailures() =>
+ Observable
+ .Merge(
+ AddFolderCommand.ThrownExceptions,
+ RefreshCacheSizeCommand.ThrownExceptions,
+ ClearCacheCommand.ThrownExceptions,
+ SaveCommand.ThrownExceptions,
+ CancelCommand.ThrownExceptions)
+ .Subscribe(ex =>
+ {
+ _logger.LogError(ex, "A settings command failed");
+ Message = "Не удалось выполнить действие — подробности в журнале";
+ })
+ .AddTo(Subscriptions);
+}
+
+/// What the settings dialog left behind once it closed.
+/// False when the user cancelled or closed the window.
+/// True when the change affects what the library contains.
+/// The snapshot that was written, so the caller can wait for it to load.
+public sealed record SettingsDialogOutcome(bool Saved, bool RescanRequired, AppSettings? Settings)
+{
+ public static SettingsDialogOutcome Cancelled { get; } = new(false, false, null);
+}
+
+public sealed record ThemeOption(ThemeMode Mode, string Label)
+{
+ public static IReadOnlyList All { get; } =
+ [
+ new(ThemeMode.System, "Как в системе"),
+ new(ThemeMode.Light, "Светлая"),
+ new(ThemeMode.Dark, "Тёмная"),
+ ];
+}
diff --git a/src/PLib.Desktop/Views/MainWindow.axaml b/src/PLib.Desktop/Views/MainWindow.axaml
index 7bef7fc..0c66494 100644
--- a/src/PLib.Desktop/Views/MainWindow.axaml
+++ b/src/PLib.Desktop/Views/MainWindow.axaml
@@ -145,6 +145,10 @@
+
+
+
diff --git a/tests/PLib.Tests/Settings/AppSettingsStoreTests.cs b/tests/PLib.Tests/Settings/AppSettingsStoreTests.cs
new file mode 100644
index 0000000..c516cfb
--- /dev/null
+++ b/tests/PLib.Tests/Settings/AppSettingsStoreTests.cs
@@ -0,0 +1,134 @@
+using Microsoft.Extensions.Configuration;
+using PLib.Application.Library;
+using PLib.Desktop.Services;
+using PLib.Desktop.Settings;
+using PLib.Infrastructure.Storage;
+using Shouldly;
+
+namespace PLib.Tests.Settings;
+
+///
+/// The settings file is not just storage — it is a live configuration source. These tests
+/// close the loop: what the store writes has to be what the options binder reads back.
+///
+public sealed class AppSettingsStoreTests : IDisposable
+{
+ private readonly TempPaths _paths = new();
+
+ public void Dispose() => _paths.Dispose();
+
+ [Fact]
+ public async Task Saved_settings_are_readable_by_the_configuration_binder()
+ {
+ var settings = new AppSettings
+ {
+ Folders = [@"C:\videos", @"D:\more videos"],
+ ThumbnailWidth = 640,
+ ThumbnailPositionRatio = 0.25,
+ MaxIndexingConcurrency = 8,
+ MinimumFileSizeInBytes = 2_097_152,
+ Theme = ThemeMode.Light,
+ };
+
+ await new JsonAppSettingsStore(_paths).SaveAsync(settings, Token);
+
+ var (library, appearance) = Reload();
+
+ library.Folders.ShouldBe(settings.Folders);
+ library.ThumbnailWidth.ShouldBe(640);
+ library.ThumbnailPositionRatio.ShouldBe(0.25);
+ library.MaxIndexingConcurrency.ShouldBe(8);
+ library.MinimumFileSizeInBytes.ShouldBe(2_097_152);
+ appearance.Theme.ShouldBe(ThemeMode.Light);
+ }
+
+ [Fact]
+ public async Task Removing_a_folder_actually_shortens_the_stored_list()
+ {
+ var store = new JsonAppSettingsStore(_paths);
+ var settings = Sample with { Folders = [@"C:\a", @"C:\b", @"C:\c"] };
+
+ await store.SaveAsync(settings, Token);
+ await store.SaveAsync(settings with { Folders = [@"C:\a", @"C:\c"] }, Token);
+
+ // Configuration merges arrays by index, so a shorter list is the case most likely
+ // to leave a stale entry behind.
+ Reload().Library.Folders.ShouldBe([@"C:\a", @"C:\c"]);
+ }
+
+ [Fact]
+ public async Task Keys_the_settings_screen_does_not_know_about_survive_a_save()
+ {
+ await File.WriteAllTextAsync(
+ Path.Combine(_paths.DataDirectory, "settings.json"),
+ """{ "Library": { "VideoExtensions": [ ".mp4" ] }, "Experimental": { "Flag": true } }""",
+ Token);
+
+ await new JsonAppSettingsStore(_paths).SaveAsync(Sample, Token);
+
+ var configuration = Build();
+ configuration["Experimental:Flag"].ShouldBe("True");
+ configuration["Library:VideoExtensions:0"].ShouldBe(".mp4");
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void Only_changes_that_affect_the_contents_of_the_library_ask_for_a_rescan(bool cosmeticOnly)
+ {
+ var changed = cosmeticOnly
+ ? Sample with { ThumbnailWidth = 999, Theme = ThemeMode.Dark }
+ : Sample with { Folders = [@"C:\elsewhere"] };
+
+ changed.RequiresRescanComparedTo(Sample).ShouldBe(!cosmeticOnly);
+ }
+
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ private static AppSettings Sample => new()
+ {
+ Folders = [@"C:\videos"],
+ ThumbnailWidth = 480,
+ ThumbnailPositionRatio = 0.15,
+ MaxIndexingConcurrency = 4,
+ MinimumFileSizeInBytes = 65_536,
+ Theme = ThemeMode.System,
+ };
+
+ private IConfigurationRoot Build() => new ConfigurationBuilder()
+ .AddJsonFile(Path.Combine(_paths.DataDirectory, "settings.json"), optional: false)
+ .Build();
+
+ private (LibraryOptions Library, AppearanceOptions Appearance) Reload()
+ {
+ var configuration = Build();
+
+ var library = new LibraryOptions();
+ configuration.GetSection(LibraryOptions.SectionName).Bind(library);
+
+ var appearance = new AppearanceOptions();
+ configuration.GetSection(AppearanceOptions.SectionName).Bind(appearance);
+
+ return (library, appearance);
+ }
+
+ private sealed class TempPaths : IAppPaths, IDisposable
+ {
+ public TempPaths()
+ {
+ DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}");
+ ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
+ DatabaseFile = Path.Combine(DataDirectory, "library.db");
+
+ Directory.CreateDirectory(ThumbnailDirectory);
+ }
+
+ public string DataDirectory { get; }
+
+ public string ThumbnailDirectory { get; }
+
+ public string DatabaseFile { get; }
+
+ public void Dispose() => Directory.Delete(DataDirectory, recursive: true);
+ }
+}