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:
@@ -13,8 +13,10 @@
|
|||||||
кадры, снятые по всей длительности.
|
кадры, снятые по всей длительности.
|
||||||
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
|
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
|
||||||
- Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки
|
- Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки
|
||||||
библиотеки с удалением, параметры превью и сканирования, тема, очистка кэша. Всё пишется
|
библиотеки с удалением, параметры превью и сканирования, тема. Всё пишется
|
||||||
в `settings.json` и подхватывается без перезапуска.
|
в `settings.json` и подхватывается без перезапуска.
|
||||||
|
- Очистка собранных данных по видам — постеры, анимированные превью, отпечатки, технические
|
||||||
|
метаданные — каждый со своей кнопкой и текущим объёмом.
|
||||||
- Светлая, тёмная и системная темы; выбор запоминается.
|
- Светлая, тёмная и системная темы; выбор запоминается.
|
||||||
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
|
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
|
||||||
перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход —
|
перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход —
|
||||||
@@ -144,6 +146,13 @@ dotnet test
|
|||||||
Постеры и анимации различаются лишь тем, что просят у ffmpeg, а хозяйство у них одно, и
|
Постеры и анимации различаются лишь тем, что просят у ffmpeg, а хозяйство у них одно, и
|
||||||
описано оно один раз: иначе размер кэша в настройках начал бы врать в тот же день, когда
|
описано оно один раз: иначе размер кэша в настройках начал бы врать в тот же день, когда
|
||||||
появился второй вид файлов.
|
появился второй вид файлов.
|
||||||
|
- **Очистка — по видам, и только того, что пересобирается.** `LibraryDataKind` перечисляет
|
||||||
|
ровно то, что выводится из самих файлов: постеры, анимации, отпечатки, техметаданные.
|
||||||
|
Цена очистки любого из них — время, а не информация, поэтому кнопка не спрашивает
|
||||||
|
подтверждения. Названия, теги, коллекции и прогресс просмотра в этот список сознательно
|
||||||
|
не входят: их не вернёт никакое пересканирование, так что соседство с ними в одном ряду
|
||||||
|
кнопок было бы ловушкой. Ссылки забываются раньше, чем удаляются файлы, — прерывание
|
||||||
|
в обратном порядке оставило бы библиотеку с путями в никуда.
|
||||||
|
|
||||||
## Данные
|
## Данные
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,18 @@ public interface ILibraryService
|
|||||||
IReadOnlyList<string> folders,
|
IReadOnlyList<string> folders,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>Disk space currently taken by cached poster frames, in bytes.</summary>
|
/// <summary>
|
||||||
Task<long> GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default);
|
/// 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>
|
/// <summary>
|
||||||
/// Throws every poster frame away and forgets the paths, so the next scan renders them
|
/// Throws the named kinds of derived data away — files as well as the references to them —
|
||||||
/// from scratch. Useful after changing the thumbnail width or capture position.
|
/// 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>
|
/// </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>
|
/// <summary>Remembers where playback stopped so the video can be resumed later.</summary>
|
||||||
Task SaveProgressAsync(Guid videoId, TimeSpan position, CancellationToken cancellationToken = default);
|
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);
|
||||||
@@ -147,34 +147,72 @@ public sealed class LibraryService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Every cache of files derived from the videos, so none is ever forgotten.</summary>
|
public async Task<IReadOnlyList<LibraryDataUsage>> GetDataUsageAsync(
|
||||||
private IEnumerable<IMediaArtifactCache> ArtifactCaches => [thumbnailGenerator, previewGenerator];
|
CancellationToken cancellationToken = default)
|
||||||
|
|
||||||
public async Task<long> GetThumbnailCacheSizeAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
{
|
||||||
var sizes = await Task.WhenAll(
|
var items = await repository.GetAllAsync(cancellationToken);
|
||||||
ArtifactCaches.Select(cache => cache.GetCacheSizeInBytesAsync(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);
|
var items = await repository.GetAllAsync(cancellationToken);
|
||||||
|
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (kinds.HasFlag(LibraryDataKind.Thumbnails))
|
||||||
{
|
{
|
||||||
item.DetachThumbnail();
|
item.DetachThumbnail();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kinds.HasFlag(LibraryDataKind.AnimatedPreviews))
|
||||||
|
{
|
||||||
item.DetachPreview();
|
item.DetachPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forget the paths before deleting the files. Interrupted the other way round, the
|
if (kinds.HasFlag(LibraryDataKind.PerceptualHashes))
|
||||||
// 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.
|
item.ApplyPerceptualHash(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kinds.HasFlag(LibraryDataKind.TechnicalMetadata))
|
||||||
|
{
|
||||||
|
item.ApplyTechnicalInfo(VideoTechnicalInfo.Unknown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
await repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
var removed = await Task.WhenAll(ArtifactCaches.Select(cache => cache.ClearAsync(cancellationToken)));
|
var removed = 0;
|
||||||
logger.LogInformation("Cleared {Count} cached images on request", removed.Sum());
|
|
||||||
|
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(
|
public async IAsyncEnumerable<LibraryScanEvent> ScanAsync(
|
||||||
|
|||||||
@@ -30,8 +30,13 @@ public sealed class FilmstripImage : Control
|
|||||||
public static readonly StyledProperty<bool> IsPlayingProperty =
|
public static readonly StyledProperty<bool> IsPlayingProperty =
|
||||||
AvaloniaProperty.Register<FilmstripImage, bool>(nameof(IsPlaying));
|
AvaloniaProperty.Register<FilmstripImage, bool>(nameof(IsPlaying));
|
||||||
|
|
||||||
/// <summary>Slow enough to read as a preview rather than a flicker, and cheap to draw.</summary>
|
/// <summary>
|
||||||
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.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan FrameInterval = TimeSpan.FromMilliseconds(450);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How long the pointer has to rest before anything is decoded. Without it, dragging the
|
/// 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();
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
var panel = scope.ServiceProvider.GetRequiredService<SettingsViewModel>();
|
var panel = scope.ServiceProvider.GetRequiredService<SettingsViewModel>();
|
||||||
|
|
||||||
// Fill in the cache size before the panel appears, so the number never pops in late.
|
// Fill in the usage figures before the panel appears, so they never pop in late.
|
||||||
await panel.RefreshCacheSizeCommand.Execute().FirstAsync();
|
await panel.RefreshUsageCommand.Execute().FirstAsync();
|
||||||
|
|
||||||
SettingsPanel = panel;
|
SettingsPanel = panel;
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
|||||||
|
|
||||||
private readonly Subject<SettingsDialogOutcome> _closed = new();
|
private readonly Subject<SettingsDialogOutcome> _closed = new();
|
||||||
|
|
||||||
/// <summary>Set once the cache has been wiped, which always forces a rescan on close.</summary>
|
/// <summary>Set once derived data has been wiped, which always forces a rescan on close.</summary>
|
||||||
private bool _thumbnailsWereReset;
|
private bool _dataWasReset;
|
||||||
|
|
||||||
public SettingsViewModel(
|
public SettingsViewModel(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
@@ -60,8 +60,41 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
|||||||
SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
|
SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
|
||||||
|
|
||||||
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
|
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
|
||||||
RefreshCacheSizeCommand = ReactiveCommand.CreateFromTask(RefreshCacheSizeAsync);
|
RefreshUsageCommand = ReactiveCommand.CreateFromTask(RefreshUsageAsync);
|
||||||
ClearCacheCommand = ReactiveCommand.CreateFromTask(ClearCacheAsync);
|
|
||||||
|
// 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);
|
SaveCommand = ReactiveCommand.CreateFromTask(SaveAsync);
|
||||||
CancelCommand = ReactiveCommand.Create(() => _closed.OnNext(SettingsDialogOutcome.Cancelled));
|
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> 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; }
|
public ReactiveCommand<RxVoid, RxVoid> SaveCommand { get; }
|
||||||
|
|
||||||
@@ -111,9 +150,6 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
|||||||
[Reactive]
|
[Reactive]
|
||||||
public partial ThemeOption SelectedTheme { get; set; }
|
public partial ThemeOption SelectedTheme { get; set; }
|
||||||
|
|
||||||
[Reactive]
|
|
||||||
public partial string CacheSizeText { get; set; }
|
|
||||||
|
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public partial bool IsBusy { get; set; }
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
@@ -165,16 +201,20 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
|||||||
private FolderEntryViewModel CreateEntry(string path) =>
|
private FolderEntryViewModel CreateEntry(string path) =>
|
||||||
new(path, entry => Folders.Remove(entry));
|
new(path, entry => Folders.Remove(entry));
|
||||||
|
|
||||||
private async Task RefreshCacheSizeAsync()
|
private async Task RefreshUsageAsync()
|
||||||
{
|
{
|
||||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
|
|
||||||
var bytes = await library.GetThumbnailCacheSizeAsync();
|
var usage = await library.GetDataUsageAsync();
|
||||||
CacheSizeText = DisplayText.FileSize(bytes);
|
|
||||||
|
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;
|
IsBusy = true;
|
||||||
|
|
||||||
@@ -183,13 +223,13 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
|||||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
|
|
||||||
await library.ResetThumbnailsAsync();
|
await library.ResetAsync(kinds);
|
||||||
await RefreshCacheSizeAsync();
|
await RefreshUsageAsync();
|
||||||
|
|
||||||
// The frames are gone from disk and from the library, so the grid has to be
|
// The data is gone from disk and from the library, so the grid has to be rebuilt
|
||||||
// rebuilt regardless of what else the user changes before closing.
|
// regardless of what else the user changes before closing.
|
||||||
_thumbnailsWereReset = true;
|
_dataWasReset = true;
|
||||||
Message = "Кэш очищен — превью соберутся заново при следующем сканировании";
|
Message = "Очищено — недостающее соберётся при следующем сканировании";
|
||||||
}
|
}
|
||||||
finally
|
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.
|
// The theme is not read from configuration again while the app runs, so apply it here.
|
||||||
_theme.Apply(draft.Theme);
|
_theme.Apply(draft.Theme);
|
||||||
|
|
||||||
var rescan = _thumbnailsWereReset || draft.RequiresRescanComparedTo(_original);
|
var rescan = _dataWasReset || draft.RequiresRescanComparedTo(_original);
|
||||||
_closed.OnNext(new SettingsDialogOutcome(Saved: true, rescan, draft));
|
_closed.OnNext(new SettingsDialogOutcome(Saved: true, rescan, draft));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ObserveCommandFailures() =>
|
private void ObserveCommandFailures() =>
|
||||||
Observable
|
Observable
|
||||||
.Merge(
|
.Merge<Exception>(
|
||||||
|
[
|
||||||
AddFolderCommand.ThrownExceptions,
|
AddFolderCommand.ThrownExceptions,
|
||||||
RefreshCacheSizeCommand.ThrownExceptions,
|
RefreshUsageCommand.ThrownExceptions,
|
||||||
ClearCacheCommand.ThrownExceptions,
|
ClearAllCommand.ThrownExceptions,
|
||||||
SaveCommand.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 =>
|
.Subscribe(ex =>
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "A settings command failed");
|
_logger.LogError(ex, "A settings command failed");
|
||||||
|
|||||||
@@ -183,25 +183,47 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Cache -->
|
<!-- Derived data -->
|
||||||
<Border Classes="section">
|
<Border Classes="section">
|
||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
<TextBlock Classes="sectionTitle" FontSize="14" Text="Кэш превью" />
|
<TextBlock Classes="sectionTitle" FontSize="14" Text="Собранные данные" />
|
||||||
|
|
||||||
<Grid ColumnDefinitions="200,*,Auto" ColumnSpacing="12">
|
<ItemsControl ItemsSource="{Binding DataKinds}">
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="Занято на диске" />
|
<ItemsControl.ItemTemplate>
|
||||||
<TextBlock Grid.Column="1"
|
<DataTemplate x:DataType="vm:LibraryDataViewModel">
|
||||||
Classes="label"
|
<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}"
|
Foreground="{DynamicResource TextPrimaryBrush}"
|
||||||
Text="{Binding CacheSizeText}" />
|
FontSize="12.5"
|
||||||
<Button Grid.Column="2"
|
FontWeight="SemiBold" />
|
||||||
Command="{Binding ClearCacheCommand}"
|
<TextBlock Classes="label" Text="{Binding UsageText}" />
|
||||||
IsEnabled="{Binding !IsBusy}"
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Command="{Binding ClearCommand}"
|
||||||
Content="Очистить" />
|
Content="Очистить" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<TextBlock Classes="hint" Text="{Binding Hint}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<Button HorizontalAlignment="Left"
|
||||||
|
Command="{Binding ClearAllCommand}"
|
||||||
|
Content="Очистить всё" />
|
||||||
|
|
||||||
<TextBlock Classes="hint"
|
<TextBlock Classes="hint"
|
||||||
Text="Все кадры удаляются с диска, а библиотека забывает пути к ним. После закрытия настроек превью соберутся заново." />
|
Text="Всё перечисленное собирается из самих файлов, поэтому очистка стоит только времени: недостающее досчитается при следующем сканировании. Названия, теги, коллекции и прогресс просмотра здесь не трогаются — их пересканирование не вернёт." />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -231,6 +231,85 @@ public sealed class LibraryServiceTests
|
|||||||
events.OfType<LibraryScanEvent.DiscoveryCompleted>().Single().FilesFound.ShouldBe(1);
|
events.OfType<LibraryScanEvent.DiscoveryCompleted>().Single().FilesFound.ShouldBe(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Clearing_one_kind_of_derived_data_leaves_every_other_kind_alone()
|
||||||
|
{
|
||||||
|
var item = FullyIndexed();
|
||||||
|
_repository.Seed(item);
|
||||||
|
|
||||||
|
await CreateService().ResetAsync(LibraryDataKind.PerceptualHashes, Token);
|
||||||
|
|
||||||
|
item.PerceptualHash.ShouldBeNull();
|
||||||
|
|
||||||
|
// Clearing the hash is a database edit; nothing on disk has anything to do with it.
|
||||||
|
item.ThumbnailPath.ShouldNotBeNull();
|
||||||
|
item.PreviewPath.ShouldNotBeNull();
|
||||||
|
item.Duration.ShouldNotBeNull();
|
||||||
|
await _thumbnails.DidNotReceive().ClearAsync(Arg.Any<CancellationToken>());
|
||||||
|
await _previews.DidNotReceive().ClearAsync(Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Clearing_an_image_cache_deletes_the_files_as_well_as_the_references()
|
||||||
|
{
|
||||||
|
var item = FullyIndexed();
|
||||||
|
_repository.Seed(item);
|
||||||
|
|
||||||
|
await CreateService().ResetAsync(LibraryDataKind.AnimatedPreviews, Token);
|
||||||
|
|
||||||
|
// A path kept after the file was deleted would leave the card pointing at nothing.
|
||||||
|
item.PreviewPath.ShouldBeNull();
|
||||||
|
await _previews.Received(1).ClearAsync(Arg.Any<CancellationToken>());
|
||||||
|
await _thumbnails.DidNotReceive().ClearAsync(Arg.Any<CancellationToken>());
|
||||||
|
item.ThumbnailPath.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Clearing_everything_takes_all_four_kinds()
|
||||||
|
{
|
||||||
|
var item = FullyIndexed();
|
||||||
|
_repository.Seed(item);
|
||||||
|
|
||||||
|
await CreateService().ResetAsync(LibraryDataKind.All, Token);
|
||||||
|
|
||||||
|
item.ThumbnailPath.ShouldBeNull();
|
||||||
|
item.PreviewPath.ShouldBeNull();
|
||||||
|
item.PerceptualHash.ShouldBeNull();
|
||||||
|
item.Duration.ShouldBeNull();
|
||||||
|
item.IsIndexed.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Usage_says_how_far_each_kind_has_got_through_the_library()
|
||||||
|
{
|
||||||
|
_repository.Seed(FullyIndexed());
|
||||||
|
_repository.Seed(new VideoItem(@"C:\videos\b.mp4", "b", 5_000, DateTimeOffset.UnixEpoch));
|
||||||
|
|
||||||
|
_previews.GetCacheSizeInBytesAsync(Arg.Any<CancellationToken>()).Returns(4_096L);
|
||||||
|
|
||||||
|
var usage = (await CreateService().GetDataUsageAsync(Token)).ToDictionary(entry => entry.Kind);
|
||||||
|
|
||||||
|
usage[LibraryDataKind.PerceptualHashes].ShouldSatisfyAllConditions(
|
||||||
|
entry => entry.Videos.ShouldBe(2),
|
||||||
|
entry => entry.Present.ShouldBe(1));
|
||||||
|
|
||||||
|
// Only the caches that are files on disk have a size to report.
|
||||||
|
usage[LibraryDataKind.AnimatedPreviews].Bytes.ShouldBe(4_096L);
|
||||||
|
usage[LibraryDataKind.PerceptualHashes].Bytes.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
private static VideoItem FullyIndexed()
|
||||||
|
{
|
||||||
|
var item = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
||||||
|
item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
|
||||||
|
item.AttachThumbnail(@"C:\cache\a.jpg");
|
||||||
|
item.AttachPreview(@"C:\cache\a.strip.jpg", 12);
|
||||||
|
item.ApplyPerceptualHash(1);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
private static DiscoveredVideoFile File(string path, long sizeInBytes = 10_000) =>
|
private static DiscoveredVideoFile File(string path, long sizeInBytes = 10_000) =>
|
||||||
new(path, sizeInBytes, DateTimeOffset.UnixEpoch);
|
new(path, sizeInBytes, DateTimeOffset.UnixEpoch);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using ReactiveUI.Builder;
|
||||||
|
|
||||||
|
namespace PLib.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brings ReactiveUI up before any test touches a view model.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ReactiveUI 24 refuses to serve <c>WhenAnyValue</c> until it has been initialised, and the
|
||||||
|
/// application does that in <c>Program.Main</c> — which a test host never runs. Only the core
|
||||||
|
/// services are registered here: platform services are about dispatchers and bindings, and a
|
||||||
|
/// view model under test has neither.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class ReactiveUiBootstrap
|
||||||
|
{
|
||||||
|
[ModuleInitializer]
|
||||||
|
internal static void Initialize() =>
|
||||||
|
// BuildApp rather than Build: only the former marks ReactiveUI as initialised, and
|
||||||
|
// that flag is exactly what WhenAnyValue checks.
|
||||||
|
RxAppBuilder.CreateReactiveUIBuilder()
|
||||||
|
.WithCoreServices()
|
||||||
|
.BuildApp();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user