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
@@ -16,14 +16,18 @@ public interface ILibraryService
IReadOnlyList<string> folders,
CancellationToken cancellationToken = default);
/// <summary>Disk space currently taken by cached poster frames, in bytes.</summary>
Task<long> GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default);
/// <summary>
/// What each kind of derived data currently costs, one entry per
/// <see cref="LibraryDataKind"/>, so the user can see what clearing it would free.
/// </summary>
Task<IReadOnlyList<LibraryDataUsage>> GetDataUsageAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// Throws the named kinds of derived data away — files as well as the references to them
/// so the next scan rebuilds them from scratch. Useful after changing a setting that
/// governs how they are produced, or when one of them is suspected of being wrong.
/// </summary>
Task ResetThumbnailsAsync(CancellationToken cancellationToken = default);
Task ResetAsync(LibraryDataKind kinds, CancellationToken cancellationToken = default);
/// <summary>Remembers where playback stopped so the video can be resumed later.</summary>
Task SaveProgressAsync(Guid videoId, TimeSpan position, CancellationToken cancellationToken = default);
@@ -0,0 +1,36 @@
namespace PLib.Application.Library;
/// <summary>
/// The kinds of data PLib derives from the video files themselves.
/// </summary>
/// <remarks>
/// Everything named here can be thrown away and rebuilt by a scan: the cost of clearing it is
/// time, never information. What the user typed or watched — titles, tags, collections, where
/// playback stopped — is deliberately absent, because no amount of rescanning brings it back.
/// </remarks>
[Flags]
public enum LibraryDataKind
{
None = 0,
/// <summary>Poster frames, on disk and as paths on the videos.</summary>
Thumbnails = 1 << 0,
/// <summary>Animated previews, on disk and as paths on the videos.</summary>
AnimatedPreviews = 1 << 1,
/// <summary>Perceptual hashes; only duplicate search reads them.</summary>
PerceptualHashes = 1 << 2,
/// <summary>Duration, resolution and codec, as read by ffprobe.</summary>
TechnicalMetadata = 1 << 3,
All = Thumbnails | AnimatedPreviews | PerceptualHashes | TechnicalMetadata,
}
/// <summary>What one kind of derived data currently costs.</summary>
/// <param name="Kind">Which kind this describes.</param>
/// <param name="Videos">How many videos are in the library altogether.</param>
/// <param name="Present">For how many of them this kind of data exists.</param>
/// <param name="Bytes">Disk space taken, or zero for kinds that live only in the database.</param>
public sealed record LibraryDataUsage(LibraryDataKind Kind, int Videos, int Present, long Bytes);
+53 -15
View File
@@ -147,34 +147,72 @@ public sealed class LibraryService(
}
}
/// <summary>Every cache of files derived from the videos, so none is ever forgotten.</summary>
private IEnumerable<IMediaArtifactCache> ArtifactCaches => [thumbnailGenerator, previewGenerator];
public async Task<long> GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<LibraryDataUsage>> GetDataUsageAsync(
CancellationToken cancellationToken = default)
{
var sizes = await Task.WhenAll(
ArtifactCaches.Select(cache => cache.GetCacheSizeInBytesAsync(cancellationToken)));
var items = await repository.GetAllAsync(cancellationToken);
var thumbnailBytes = await thumbnailGenerator.GetCacheSizeInBytesAsync(cancellationToken);
var previewBytes = await previewGenerator.GetCacheSizeInBytesAsync(cancellationToken);
return sizes.Sum();
return
[
new(LibraryDataKind.Thumbnails, items.Count, items.Count(x => x.ThumbnailPath is not null), thumbnailBytes),
new(LibraryDataKind.AnimatedPreviews, items.Count, items.Count(x => x.PreviewPath is not null), previewBytes),
new(LibraryDataKind.PerceptualHashes, items.Count, items.Count(x => x.PerceptualHash is not null), 0),
new(LibraryDataKind.TechnicalMetadata, items.Count, items.Count(x => x.Duration is not null), 0),
];
}
public async Task ResetThumbnailsAsync(CancellationToken cancellationToken = default)
public async Task ResetAsync(LibraryDataKind kinds, CancellationToken cancellationToken = default)
{
if (kinds == LibraryDataKind.None)
{
return;
}
var items = await repository.GetAllAsync(cancellationToken);
foreach (var item in items)
{
item.DetachThumbnail();
item.DetachPreview();
if (kinds.HasFlag(LibraryDataKind.Thumbnails))
{
item.DetachThumbnail();
}
if (kinds.HasFlag(LibraryDataKind.AnimatedPreviews))
{
item.DetachPreview();
}
if (kinds.HasFlag(LibraryDataKind.PerceptualHashes))
{
item.ApplyPerceptualHash(null);
}
if (kinds.HasFlag(LibraryDataKind.TechnicalMetadata))
{
item.ApplyTechnicalInfo(VideoTechnicalInfo.Unknown);
}
}
// 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.
// Forget the references before deleting the files. Interrupted the other way round,
// the library would point at images that no longer exist — recoverable, but only once
// a scan notices. This order leaves at worst some orphans, which the purge eats.
await repository.SaveChangesAsync(cancellationToken);
var removed = await Task.WhenAll(ArtifactCaches.Select(cache => cache.ClearAsync(cancellationToken)));
logger.LogInformation("Cleared {Count} cached images on request", removed.Sum());
var removed = 0;
if (kinds.HasFlag(LibraryDataKind.Thumbnails))
{
removed += await thumbnailGenerator.ClearAsync(cancellationToken);
}
if (kinds.HasFlag(LibraryDataKind.AnimatedPreviews))
{
removed += await previewGenerator.ClearAsync(cancellationToken);
}
logger.LogInformation("Cleared {Kinds} on request, removing {Count} files", kinds, removed);
}
public async IAsyncEnumerable<LibraryScanEvent> ScanAsync(
+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>