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 @@ - + - + - - - -