Refactor derived data management in PLib video library manager. Introduce functionality to clear specific types of derived data, including thumbnails, animated previews, perceptual hashes, and technical metadata. Update ILibraryService and LibraryService to support new data usage reporting and reset operations. Revise SettingsViewModel and UI components to reflect these changes, enhancing user control over data management. Update README.md to document new features and usage instructions.

This commit is contained in:
Leonid Pershin
2026-08-09 08:00:26 +03:00
parent 3c77baced7
commit c41a458d0b
11 changed files with 371 additions and 63 deletions
+7 -2
View File
@@ -30,8 +30,13 @@ public sealed class FilmstripImage : Control
public static readonly StyledProperty<bool> IsPlayingProperty =
AvaloniaProperty.Register<FilmstripImage, bool>(nameof(IsPlaying));
/// <summary>Slow enough to read as a preview rather than a flicker, and cheap to draw.</summary>
private static readonly TimeSpan FrameInterval = TimeSpan.FromMilliseconds(125);
/// <summary>
/// How long each frame is held. Deliberately far slower than video: the frames are taken
/// from across the whole running time, so consecutive ones are unrelated shots. Played at
/// anything like a frame rate they read as a strobe — the eye needs long enough on each to
/// actually see what is in it.
/// </summary>
private static readonly TimeSpan FrameInterval = TimeSpan.FromMilliseconds(450);
/// <summary>
/// How long the pointer has to rest before anything is decoded. Without it, dragging the
@@ -0,0 +1,45 @@
using PLib.Application.Library;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
using RxVoid = ReactiveUI.Primitives.RxVoid;
namespace PLib.Desktop.ViewModels;
/// <summary>One clearable kind of derived data, as a row in the settings panel.</summary>
public sealed partial class LibraryDataViewModel : ReactiveObject
{
public LibraryDataViewModel(
LibraryDataKind kind,
string title,
string hint,
Func<LibraryDataKind, Task> clear,
IObservable<bool> canClear)
{
Kind = kind;
Title = title;
Hint = hint;
ClearCommand = ReactiveCommand.CreateFromTask(() => clear(kind), canClear);
}
public LibraryDataKind Kind { get; }
public string Title { get; }
public string Hint { get; }
/// <summary>What this kind costs right now, in whichever unit says the most about it.</summary>
[Reactive]
public partial string UsageText { get; set; }
public ReactiveCommand<RxVoid, RxVoid> ClearCommand { get; }
/// <summary>
/// Describes the cost the way the kind is actually paid for: a cache of files is measured
/// in disk space, whereas a column in the database is only ever "how many videos have it".
/// </summary>
public void Apply(LibraryDataUsage usage)
{
var coverage = $"{usage.Present} из {usage.Videos}";
UsageText = usage.Bytes > 0 ? $"{DisplayText.FileSize(usage.Bytes)} · {coverage}" : coverage;
}
}
@@ -482,8 +482,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
await using var scope = _scopeFactory.CreateAsyncScope();
var panel = scope.ServiceProvider.GetRequiredService<SettingsViewModel>();
// Fill in the cache size before the panel appears, so the number never pops in late.
await panel.RefreshCacheSizeCommand.Execute().FirstAsync();
// Fill in the usage figures before the panel appears, so they never pop in late.
await panel.RefreshUsageCommand.Execute().FirstAsync();
SettingsPanel = panel;
@@ -32,8 +32,8 @@ public sealed partial class SettingsViewModel : ViewModelBase
private readonly Subject<SettingsDialogOutcome> _closed = new();
/// <summary>Set once the cache has been wiped, which always forces a rescan on close.</summary>
private bool _thumbnailsWereReset;
/// <summary>Set once derived data has been wiped, which always forces a rescan on close.</summary>
private bool _dataWasReset;
public SettingsViewModel(
IServiceScopeFactory scopeFactory,
@@ -60,8 +60,41 @@ public sealed partial class SettingsViewModel : ViewModelBase
SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
RefreshCacheSizeCommand = ReactiveCommand.CreateFromTask(RefreshCacheSizeAsync);
ClearCacheCommand = ReactiveCommand.CreateFromTask(ClearCacheAsync);
RefreshUsageCommand = ReactiveCommand.CreateFromTask(RefreshUsageAsync);
// One gate for every clearing button: they all talk to the same database and the same
// cache directories, so letting a second one start mid-flight buys nothing.
var idle = this.WhenAnyValue(x => x.IsBusy).Select(busy => !busy);
DataKinds =
[
new(
LibraryDataKind.Thumbnails,
"Постеры",
"Кадр-обложка карточки. Соберутся заново при следующем сканировании.",
ClearAsync,
idle),
new(
LibraryDataKind.AnimatedPreviews,
"Анимированные превью",
"Кадры, которые прокручиваются под курсором. Самое объёмное на диске.",
ClearAsync,
idle),
new(
LibraryDataKind.PerceptualHashes,
"Отпечатки",
"Нужны только для поиска дублей. Считаются дольше всего: два десятка кадров на файл.",
ClearAsync,
idle),
new(
LibraryDataKind.TechnicalMetadata,
"Технические метаданные",
"Длительность, разрешение и кодек. Без них карточка не считается готовой, поэтому файл будет переиндексирован целиком.",
ClearAsync,
idle),
];
ClearAllCommand = ReactiveCommand.CreateFromTask(() => ClearAsync(LibraryDataKind.All), idle);
SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync);
CancelCommand = ReactiveCommand.Create(() => _closed.OnNext(SettingsDialogOutcome.Cancelled));
@@ -86,9 +119,15 @@ public sealed partial class SettingsViewModel : ViewModelBase
public ReactiveCommand<RxVoid, RxVoid> AddFolderCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> RefreshCacheSizeCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> RefreshUsageCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ClearCacheCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> ClearAllCommand { get; }
/// <summary>
/// Everything a scan can rebuild, one row each. Deliberately does not include titles,
/// tags or watch progress: those are the user's, and no rescan would bring them back.
/// </summary>
public IReadOnlyList<LibraryDataViewModel> DataKinds { get; }
public ReactiveCommand<RxVoid, RxVoid> SaveCommand { get; }
@@ -111,9 +150,6 @@ public sealed partial class SettingsViewModel : ViewModelBase
[Reactive]
public partial ThemeOption SelectedTheme { get; set; }
[Reactive]
public partial string CacheSizeText { get; set; }
[Reactive]
public partial bool IsBusy { get; set; }
@@ -165,16 +201,20 @@ public sealed partial class SettingsViewModel : ViewModelBase
private FolderEntryViewModel CreateEntry(string path) =>
new(path, entry => Folders.Remove(entry));
private async Task RefreshCacheSizeAsync()
private async Task RefreshUsageAsync()
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
var bytes = await library.GetThumbnailCacheSizeAsync();
CacheSizeText = DisplayText.FileSize(bytes);
var usage = await library.GetDataUsageAsync();
foreach (var entry in usage)
{
DataKinds.FirstOrDefault(row => row.Kind == entry.Kind)?.Apply(entry);
}
}
private async Task ClearCacheAsync()
private async Task ClearAsync(LibraryDataKind kinds)
{
IsBusy = true;
@@ -183,13 +223,13 @@ public sealed partial class SettingsViewModel : ViewModelBase
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
await library.ResetThumbnailsAsync();
await RefreshCacheSizeAsync();
await library.ResetAsync(kinds);
await RefreshUsageAsync();
// 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 = "Кэш очищен — превью соберутся заново при следующем сканировании";
// The data is gone from disk and from the library, so the grid has to be rebuilt
// regardless of what else the user changes before closing.
_dataWasReset = true;
Message = "Очищенонедостающее соберётся при следующем сканировании";
}
finally
{
@@ -206,18 +246,24 @@ public sealed partial class SettingsViewModel : ViewModelBase
// 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);
var rescan = _dataWasReset || draft.RequiresRescanComparedTo(_original);
_closed.OnNext(new SettingsDialogOutcome(Saved: true, rescan, draft));
}
private void ObserveCommandFailures() =>
Observable
.Merge(
.Merge<Exception>(
[
AddFolderCommand.ThrownExceptions,
RefreshCacheSizeCommand.ThrownExceptions,
ClearCacheCommand.ThrownExceptions,
RefreshUsageCommand.ThrownExceptions,
ClearAllCommand.ThrownExceptions,
SaveCommand.ThrownExceptions,
CancelCommand.ThrownExceptions)
CancelCommand.ThrownExceptions,
// The per-kind buttons are commands too, and an unobserved failure in any of
// them would be rethrown on the UI thread by ReactiveUI's default handler.
.. DataKinds.Select(row => row.ClearCommand.ThrownExceptions),
])
.Subscribe(ex =>
{
_logger.LogError(ex, "A settings command failed");
+36 -14
View File
@@ -183,25 +183,47 @@
</StackPanel>
</Border>
<!-- Cache -->
<!-- Derived data -->
<Border Classes="section">
<StackPanel Spacing="14">
<TextBlock Classes="sectionTitle" FontSize="14" Text="Кэш превью" />
<TextBlock Classes="sectionTitle" FontSize="14" Text="Собранные данные" />
<Grid ColumnDefinitions="200,*,Auto" ColumnSpacing="12">
<TextBlock Grid.Column="0" Classes="label" Text="Занято на диске" />
<TextBlock Grid.Column="1"
Classes="label"
Foreground="{DynamicResource TextPrimaryBrush}"
Text="{Binding CacheSizeText}" />
<Button Grid.Column="2"
Command="{Binding ClearCacheCommand}"
IsEnabled="{Binding !IsBusy}"
Content="Очистить" />
</Grid>
<ItemsControl ItemsSource="{Binding DataKinds}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LibraryDataViewModel">
<Border Background="{DynamicResource CardBackgroundBrush}"
BorderBrush="{DynamicResource CardBorderBrush}"
BorderThickness="1"
CornerRadius="8"
Padding="10,8"
Margin="0,0,0,8">
<StackPanel Spacing="5">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
<StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="1">
<TextBlock Text="{Binding Title}"
Foreground="{DynamicResource TextPrimaryBrush}"
FontSize="12.5"
FontWeight="SemiBold" />
<TextBlock Classes="label" Text="{Binding UsageText}" />
</StackPanel>
<Button Grid.Column="1"
VerticalAlignment="Center"
Command="{Binding ClearCommand}"
Content="Очистить" />
</Grid>
<TextBlock Classes="hint" Text="{Binding Hint}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button HorizontalAlignment="Left"
Command="{Binding ClearAllCommand}"
Content="Очистить всё" />
<TextBlock Classes="hint"
Text="Все кадры удаляются с диска, а библиотека забывает пути к ним. После закрытия настроек превью соберутся заново." />
Text="Всё перечисленное собирается из самих файлов, поэтому очистка стоит только времени: недостающее досчитается при следующем сканировании. Названия, теги, коллекции и прогресс просмотра здесь не трогаются — их пересканирование не вернёт." />
</StackPanel>
</Border>