From c41a458d0bf5ff5d76fc3b8330046d9d1b72a70c Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 9 Aug 2026 08:00:26 +0300 Subject: [PATCH] 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. --- README.md | 11 ++- .../Library/ILibraryService.cs | 14 ++- .../Library/LibraryDataKind.cs | 36 +++++++ .../Library/LibraryService.cs | 68 +++++++++++--- src/PLib.Desktop/Controls/FilmstripImage.cs | 9 +- .../ViewModels/LibraryDataViewModel.cs | 45 +++++++++ .../ViewModels/MainWindowViewModel.cs | 4 +- .../ViewModels/SettingsViewModel.cs | 94 ++++++++++++++----- src/PLib.Desktop/Views/SettingsView.axaml | 50 +++++++--- .../PLib.Tests/Library/LibraryServiceTests.cs | 79 ++++++++++++++++ tests/PLib.Tests/ReactiveUiBootstrap.cs | 24 +++++ 11 files changed, 371 insertions(+), 63 deletions(-) create mode 100644 src/PLib.Application/Library/LibraryDataKind.cs create mode 100644 src/PLib.Desktop/ViewModels/LibraryDataViewModel.cs create mode 100644 tests/PLib.Tests/ReactiveUiBootstrap.cs diff --git a/README.md b/README.md index d14a7c5..ac7a1de 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,10 @@ кадры, снятые по всей длительности. - Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка. - Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки - библиотеки с удалением, параметры превью и сканирования, тема, очистка кэша. Всё пишется + библиотеки с удалением, параметры превью и сканирования, тема. Всё пишется в `settings.json` и подхватывается без перезапуска. +- Очистка собранных данных по видам — постеры, анимированные превью, отпечатки, технические + метаданные — каждый со своей кнопкой и текущим объёмом. - Светлая, тёмная и системная темы; выбор запоминается. - Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео, перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход — @@ -144,6 +146,13 @@ dotnet test Постеры и анимации различаются лишь тем, что просят у ffmpeg, а хозяйство у них одно, и описано оно один раз: иначе размер кэша в настройках начал бы врать в тот же день, когда появился второй вид файлов. +- **Очистка — по видам, и только того, что пересобирается.** `LibraryDataKind` перечисляет + ровно то, что выводится из самих файлов: постеры, анимации, отпечатки, техметаданные. + Цена очистки любого из них — время, а не информация, поэтому кнопка не спрашивает + подтверждения. Названия, теги, коллекции и прогресс просмотра в этот список сознательно + не входят: их не вернёт никакое пересканирование, так что соседство с ними в одном ряду + кнопок было бы ловушкой. Ссылки забываются раньше, чем удаляются файлы, — прерывание + в обратном порядке оставило бы библиотеку с путями в никуда. ## Данные diff --git a/src/PLib.Application/Library/ILibraryService.cs b/src/PLib.Application/Library/ILibraryService.cs index d303d55..895e128 100644 --- a/src/PLib.Application/Library/ILibraryService.cs +++ b/src/PLib.Application/Library/ILibraryService.cs @@ -16,14 +16,18 @@ public interface ILibraryService IReadOnlyList folders, CancellationToken cancellationToken = default); - /// Disk space currently taken by cached poster frames, in bytes. - Task GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default); + /// + /// What each kind of derived data currently costs, one entry per + /// , so the user can see what clearing it would free. + /// + Task> GetDataUsageAsync(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. + /// 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. /// - Task ResetThumbnailsAsync(CancellationToken cancellationToken = default); + Task ResetAsync(LibraryDataKind kinds, CancellationToken cancellationToken = default); /// Remembers where playback stopped so the video can be resumed later. Task SaveProgressAsync(Guid videoId, TimeSpan position, CancellationToken cancellationToken = default); diff --git a/src/PLib.Application/Library/LibraryDataKind.cs b/src/PLib.Application/Library/LibraryDataKind.cs new file mode 100644 index 0000000..18b214f --- /dev/null +++ b/src/PLib.Application/Library/LibraryDataKind.cs @@ -0,0 +1,36 @@ +namespace PLib.Application.Library; + +/// +/// The kinds of data PLib derives from the video files themselves. +/// +/// +/// 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. +/// +[Flags] +public enum LibraryDataKind +{ + None = 0, + + /// Poster frames, on disk and as paths on the videos. + Thumbnails = 1 << 0, + + /// Animated previews, on disk and as paths on the videos. + AnimatedPreviews = 1 << 1, + + /// Perceptual hashes; only duplicate search reads them. + PerceptualHashes = 1 << 2, + + /// Duration, resolution and codec, as read by ffprobe. + TechnicalMetadata = 1 << 3, + + All = Thumbnails | AnimatedPreviews | PerceptualHashes | TechnicalMetadata, +} + +/// What one kind of derived data currently costs. +/// Which kind this describes. +/// How many videos are in the library altogether. +/// For how many of them this kind of data exists. +/// Disk space taken, or zero for kinds that live only in the database. +public sealed record LibraryDataUsage(LibraryDataKind Kind, int Videos, int Present, long Bytes); diff --git a/src/PLib.Application/Library/LibraryService.cs b/src/PLib.Application/Library/LibraryService.cs index 365a455..60ceb91 100644 --- a/src/PLib.Application/Library/LibraryService.cs +++ b/src/PLib.Application/Library/LibraryService.cs @@ -147,34 +147,72 @@ public sealed class LibraryService( } } - /// Every cache of files derived from the videos, so none is ever forgotten. - private IEnumerable ArtifactCaches => [thumbnailGenerator, previewGenerator]; - - public async Task GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default) + public async Task> 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 ScanAsync( diff --git a/src/PLib.Desktop/Controls/FilmstripImage.cs b/src/PLib.Desktop/Controls/FilmstripImage.cs index 69f9a50..eb3a251 100644 --- a/src/PLib.Desktop/Controls/FilmstripImage.cs +++ b/src/PLib.Desktop/Controls/FilmstripImage.cs @@ -30,8 +30,13 @@ public sealed class FilmstripImage : Control public static readonly StyledProperty IsPlayingProperty = AvaloniaProperty.Register(nameof(IsPlaying)); - /// Slow enough to read as a preview rather than a flicker, and cheap to draw. - private static readonly TimeSpan FrameInterval = TimeSpan.FromMilliseconds(125); + /// + /// 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. + /// + private static readonly TimeSpan FrameInterval = TimeSpan.FromMilliseconds(450); /// /// How long the pointer has to rest before anything is decoded. Without it, dragging the diff --git a/src/PLib.Desktop/ViewModels/LibraryDataViewModel.cs b/src/PLib.Desktop/ViewModels/LibraryDataViewModel.cs new file mode 100644 index 0000000..3667a8b --- /dev/null +++ b/src/PLib.Desktop/ViewModels/LibraryDataViewModel.cs @@ -0,0 +1,45 @@ +using PLib.Application.Library; +using ReactiveUI; +using ReactiveUI.SourceGenerators; +using RxVoid = ReactiveUI.Primitives.RxVoid; + +namespace PLib.Desktop.ViewModels; + +/// One clearable kind of derived data, as a row in the settings panel. +public sealed partial class LibraryDataViewModel : ReactiveObject +{ + public LibraryDataViewModel( + LibraryDataKind kind, + string title, + string hint, + Func clear, + IObservable 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; } + + /// What this kind costs right now, in whichever unit says the most about it. + [Reactive] + public partial string UsageText { get; set; } + + public ReactiveCommand ClearCommand { get; } + + /// + /// 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". + /// + public void Apply(LibraryDataUsage usage) + { + var coverage = $"{usage.Present} из {usage.Videos}"; + UsageText = usage.Bytes > 0 ? $"{DisplayText.FileSize(usage.Bytes)} · {coverage}" : coverage; + } +} diff --git a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs index a1e8006..32fb3aa 100644 --- a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs +++ b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs @@ -482,8 +482,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase await using var scope = _scopeFactory.CreateAsyncScope(); var panel = scope.ServiceProvider.GetRequiredService(); - // 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; diff --git a/src/PLib.Desktop/ViewModels/SettingsViewModel.cs b/src/PLib.Desktop/ViewModels/SettingsViewModel.cs index cd2498d..608bd90 100644 --- a/src/PLib.Desktop/ViewModels/SettingsViewModel.cs +++ b/src/PLib.Desktop/ViewModels/SettingsViewModel.cs @@ -32,8 +32,8 @@ public sealed partial class SettingsViewModel : ViewModelBase private readonly Subject _closed = new(); - /// Set once the cache has been wiped, which always forces a rescan on close. - private bool _thumbnailsWereReset; + /// Set once derived data has been wiped, which always forces a rescan on close. + 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 AddFolderCommand { get; } - public ReactiveCommand RefreshCacheSizeCommand { get; } + public ReactiveCommand RefreshUsageCommand { get; } - public ReactiveCommand ClearCacheCommand { get; } + public ReactiveCommand ClearAllCommand { get; } + + /// + /// 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. + /// + public IReadOnlyList DataKinds { get; } public ReactiveCommand 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(); - 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(); - 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( + [ 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"); diff --git a/src/PLib.Desktop/Views/SettingsView.axaml b/src/PLib.Desktop/Views/SettingsView.axaml index ca7c45d..9cfbec3 100644 --- a/src/PLib.Desktop/Views/SettingsView.axaml +++ b/src/PLib.Desktop/Views/SettingsView.axaml @@ -183,25 +183,47 @@ - + - + - - - -