diff --git a/README.md b/README.md index 6800ca3..f32d24f 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ библиотеки с удалением, параметры превью и сканирования, тема. Всё пишется в `settings.json` и подхватывается без перезапуска. - Очистка собранных данных по видам — постеры, анимированные превью, отпечатки, технические - метаданные — каждый со своей кнопкой и текущим объёмом. + метаданные, изображения меток, теги, актёры, студии, коллекции — каждый со своей кнопкой + и текущим объёмом, плюс «очистить всё». - Источники метаданных: список GraphQL-эндпойнтов (название, адрес, API-ключ) со схемой stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия. @@ -238,13 +239,18 @@ dotnet test Постеры и анимации различаются лишь тем, что просят у ffmpeg, а хозяйство у них одно, и описано оно один раз: иначе размер кэша в настройках начал бы врать в тот же день, когда появился второй вид файлов. -- **Очистка — по видам, и только того, что пересобирается.** `LibraryDataKind` перечисляет - ровно то, что выводится из самих файлов: постеры, анимации, отпечатки, техметаданные. - Цена очистки любого из них — время, а не информация, поэтому кнопка не спрашивает - подтверждения. Названия, теги, коллекции и прогресс просмотра в этот список сознательно - не входят: их не вернёт никакое пересканирование, так что соседство с ними в одном ряду - кнопок было бы ловушкой. Ссылки забываются раньше, чем удаляются файлы, — прерывание - в обратном порядке оставило бы библиотеку с путями в никуда. +- **Очистка — по видам, и вид говорит, чем платишь.** Первые четыре пункта + `LibraryDataKind` выводятся из самих файлов: постеры, анимации, отпечатки, + техметаданные. Их очистка стоит времени, а не информации. + Метки поначалу в список не входили — как пользовательские данные. Это перестало быть + правдой, когда теги, актёров и студии начали приезжать из источников метаданных пачками: + «очистить всё», оставляющее их, просто не делает того, что обещает. Теперь каждый вид + метки — своя кнопка, и подпись под каждой честно говорит, чем именно она восстановится: + повторным применением совпадения, или ничем, как в случае коллекций. + Ссылки забываются раньше, чем удаляются файлы, — прерывание в обратном порядке оставило + бы библиотеку с путями в никуда. А метки снимаются с загруженных видео явно, а не + каскадом в БД: каскад вычистил бы строки связи и оставил каждое видео в памяти всё ещё + держащим метку — на диске верно, на экране нет, пока что-нибудь не перечитает. ## Данные diff --git a/src/PLib.Application/Library/LibraryDataKind.cs b/src/PLib.Application/Library/LibraryDataKind.cs index 0a59246..991a2f5 100644 --- a/src/PLib.Application/Library/LibraryDataKind.cs +++ b/src/PLib.Application/Library/LibraryDataKind.cs @@ -32,12 +32,32 @@ public enum LibraryDataKind /// RemoteImages = 1 << 4, - All = Thumbnails | AnimatedPreviews | PerceptualHashes | TechnicalMetadata | RemoteImages, + /// Tags, however they got there. + Tags = 1 << 5, + + /// Performers, along with their pictures. + Performers = 1 << 6, + + /// Studios, along with their logos. + Studios = 1 << 7, + + /// + /// Collections. The one kind nothing else creates — a scan never makes one and no metadata + /// source knows about them, so clearing these throws away work only the user can redo. + /// + Collections = 1 << 8, + + All = Thumbnails | AnimatedPreviews | PerceptualHashes | TechnicalMetadata | RemoteImages | + Tags | Performers | Studios | Collections, } /// 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. +/// How many of this kind exist. +/// +/// What is a share of — the size of the library, for the kinds that +/// are produced per video. Null for the kinds that are not: a count of tags is a whole answer, +/// and "40 of 40" would only invite the reader to look for the missing ones. +/// /// 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); +public sealed record LibraryDataUsage(LibraryDataKind Kind, int Present, int? Total, long Bytes); diff --git a/src/PLib.Application/Library/LibraryService.cs b/src/PLib.Application/Library/LibraryService.cs index 10de4ff..a5ab03d 100644 --- a/src/PLib.Application/Library/LibraryService.cs +++ b/src/PLib.Application/Library/LibraryService.cs @@ -412,23 +412,59 @@ public sealed class LibraryService( var previewBytes = await previewGenerator.GetCacheSizeInBytesAsync(cancellationToken); var imageBytes = await remoteImages.GetCacheSizeInBytesAsync(cancellationToken); + int Labels(LabelKind kind) => allLabels.Count(label => label.Kind == kind); + 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), + new(LibraryDataKind.Thumbnails, items.Count(x => x.ThumbnailPath is not null), items.Count, thumbnailBytes), + new(LibraryDataKind.AnimatedPreviews, items.Count(x => x.PreviewPath is not null), items.Count, previewBytes), + new(LibraryDataKind.PerceptualHashes, items.Count(x => x.PerceptualHash is not null), items.Count, 0), + new(LibraryDataKind.TechnicalMetadata, items.Count(x => x.Duration is not null), items.Count, 0), // Counted against the labels rather than the videos: most tags will never have a // picture, and "12 из 4000 видео" would read as a failure rather than a fact. new( LibraryDataKind.RemoteImages, - allLabels.Count, allLabels.Count(x => x.ImagePath is not null), + allLabels.Count, imageBytes), + + // A count of tags is a whole answer; there is no total they are a share of. + new(LibraryDataKind.Tags, Labels(LabelKind.Tag), null, 0), + new(LibraryDataKind.Performers, Labels(LabelKind.Performer), null, 0), + new(LibraryDataKind.Studios, Labels(LabelKind.Studio), null, 0), + new(LibraryDataKind.Collections, Labels(LabelKind.Collection), null, 0), ]; } + /// Which label kinds a request covers, if any. + private static IReadOnlyList LabelKindsOf(LibraryDataKind kinds) + { + var result = new List(4); + + if (kinds.HasFlag(LibraryDataKind.Tags)) + { + result.Add(LabelKind.Tag); + } + + if (kinds.HasFlag(LibraryDataKind.Performers)) + { + result.Add(LabelKind.Performer); + } + + if (kinds.HasFlag(LibraryDataKind.Studios)) + { + result.Add(LabelKind.Studio); + } + + if (kinds.HasFlag(LibraryDataKind.Collections)) + { + result.Add(LabelKind.Collection); + } + + return result; + } + public async Task ResetAsync(LibraryDataKind kinds, CancellationToken cancellationToken = default) { if (kinds == LibraryDataKind.None) @@ -436,10 +472,24 @@ public sealed class LibraryService( return; } - var items = await repository.GetAllAsync(cancellationToken); + var labelKinds = LabelKindsOf(kinds); + + // Loaded with their labels only when labels are going: the join is worth a second + // query when it has to be maintained, and pure waste when it does not. + var items = labelKinds.Count > 0 + ? await repository.GetAllWithLabelsAsync(cancellationToken) + : await repository.GetAllAsync(cancellationToken); foreach (var item in items) { + foreach (var label in item.Labels.Where(label => labelKinds.Contains(label.Kind)).ToList()) + { + // Detached here rather than left to the database's cascade. The cascade would + // clear the rows and leave every loaded video still holding the label in + // memory — right on disk, wrong on screen until something reloaded it. + item.RemoveLabel(label.Id); + } + if (kinds.HasFlag(LibraryDataKind.Thumbnails)) { item.DetachThumbnail(); @@ -469,6 +519,20 @@ public sealed class LibraryService( } } + if (labelKinds.Count > 0) + { + var doomed = (await labels.GetAllAsync(cancellationToken)) + .Where(label => labelKinds.Contains(label.Kind)) + .ToList(); + + foreach (var label in doomed) + { + await labels.RemoveAsync(label, cancellationToken); + } + + logger.LogInformation("Removing {Count} labels of kinds {Kinds}", doomed.Count, labelKinds); + } + // 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. diff --git a/src/PLib.Desktop/ViewModels/LibraryDataViewModel.cs b/src/PLib.Desktop/ViewModels/LibraryDataViewModel.cs index 3667a8b..b37026c 100644 --- a/src/PLib.Desktop/ViewModels/LibraryDataViewModel.cs +++ b/src/PLib.Desktop/ViewModels/LibraryDataViewModel.cs @@ -39,7 +39,9 @@ public sealed partial class LibraryDataViewModel : ReactiveObject /// public void Apply(LibraryDataUsage usage) { - var coverage = $"{usage.Present} из {usage.Videos}"; + ArgumentNullException.ThrowIfNull(usage); + + var coverage = usage.Total is { } total ? $"{usage.Present} из {total}" : $"{usage.Present}"; 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 07cee23..1a51bd2 100644 --- a/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs +++ b/src/PLib.Desktop/ViewModels/MainWindowViewModel.cs @@ -750,6 +750,10 @@ public sealed partial class MainWindowViewModel : ViewModelBase if (outcome.RescanRequired) { await ScanCommand.Execute(); + + // The scan puts the files back; it says nothing about labels, which the panel may + // have just cleared. Without this the grid would still filter by a tag that is gone. + await RefreshLibraryAsync(); } } diff --git a/src/PLib.Desktop/ViewModels/SettingsViewModel.cs b/src/PLib.Desktop/ViewModels/SettingsViewModel.cs index 8d83e52..372c974 100644 --- a/src/PLib.Desktop/ViewModels/SettingsViewModel.cs +++ b/src/PLib.Desktop/ViewModels/SettingsViewModel.cs @@ -101,6 +101,30 @@ public sealed partial class SettingsViewModel : ViewModelBase "Фото актёров и логотипы студий. Скачиваются из источника метаданных, а не из файлов, поэтому сканирование их не вернёт — только повторное применение совпадения.", ClearAsync, idle), + new( + LibraryDataKind.Tags, + "Теги", + "Все теги и их связи с видео — и пришедшие из источника, и добавленные вручную: различить их сейчас невозможно.", + ClearAsync, + idle), + new( + LibraryDataKind.Performers, + "Актёры", + "Все актёры вместе со связями и фото. Вернутся повторным применением совпадений.", + ClearAsync, + idle), + new( + LibraryDataKind.Studios, + "Студии", + "Все студии вместе со связями и логотипами. Вернутся повторным применением совпадений.", + ClearAsync, + idle), + new( + LibraryDataKind.Collections, + "Коллекции", + "Коллекции целиком. Их не создаёт ни сканирование, ни источник метаданных, поэтому восстановить их можно только вручную.", + ClearAsync, + idle), ]; ClearAllCommand = ReactiveCommand.CreateFromTask(() => ClearAsync(LibraryDataKind.All), idle); diff --git a/src/PLib.Desktop/Views/SettingsView.axaml b/src/PLib.Desktop/Views/SettingsView.axaml index 806cb30..d72f08e 100644 --- a/src/PLib.Desktop/Views/SettingsView.axaml +++ b/src/PLib.Desktop/Views/SettingsView.axaml @@ -274,7 +274,7 @@ Content="Очистить всё" /> + Text="Первые четыре пункта собираются из самих файлов: очистка стоит только времени, недостающее досчитается при следующем сканировании. Остальное приходит из источников метаданных или от вас — сканирование его не вернёт, а коллекции не вернёт ничто. «Очистить всё» берёт весь список, включая их." /> diff --git a/tests/PLib.Tests/Library/LibraryServiceTests.cs b/tests/PLib.Tests/Library/LibraryServiceTests.cs index 9e9dfe7..7e03c2a 100644 --- a/tests/PLib.Tests/Library/LibraryServiceTests.cs +++ b/tests/PLib.Tests/Library/LibraryServiceTests.cs @@ -283,6 +283,81 @@ public sealed class LibraryServiceTests item.IsIndexed.ShouldBeFalse(); } + [Fact] + public async Task Clearing_one_kind_of_label_leaves_the_other_kinds_standing() + { + var item = FullyIndexed(); + _repository.Seed(item); + + var service = CreateService(); + await service.AttachLabelAsync(item.Id, "драма", LabelKind.Tag, Token); + await service.AttachLabelAsync(item.Id, "Актёр", LabelKind.Performer, Token); + await service.AttachLabelAsync(item.Id, "Моя подборка", LabelKind.Collection, Token); + + await service.ResetAsync(LibraryDataKind.Performers, Token); + + // Gone from the library, not merely detached: the label is the relation, so removing + // it takes every attachment with it. + var remaining = await service.GetLabelsAsync(Token); + remaining.Select(label => label.Kind).ShouldBe([LabelKind.Tag, LabelKind.Collection], ignoreOrder: true); + item.Labels.ShouldNotContain(label => label.Kind == LabelKind.Performer); + } + + [Fact] + public async Task Clearing_everything_now_takes_the_labels_as_well() + { + var item = FullyIndexed(); + _repository.Seed(item); + + var service = CreateService(); + await service.AttachLabelAsync(item.Id, "драма", LabelKind.Tag, Token); + await service.AttachLabelAsync(item.Id, "Студия", LabelKind.Studio, Token); + await service.AttachLabelAsync(item.Id, "Моя подборка", LabelKind.Collection, Token); + + await service.ResetAsync(LibraryDataKind.All, Token); + + // "Everything" that left the labels behind was the complaint that put them here. + (await service.GetLabelsAsync(Token)).ShouldBeEmpty(); + item.ThumbnailPath.ShouldBeNull(); + item.PerceptualHash.ShouldBeNull(); + } + + [Fact] + public async Task Clearing_the_images_does_not_take_the_labels_that_wore_them() + { + var item = FullyIndexed(); + _repository.Seed(item); + + var service = CreateService(); + var performer = await service.AttachLabelAsync(item.Id, "Актёр", LabelKind.Performer, Token); + performer.AttachImage(@"C:\cache\images\face.jpg"); + + await service.ResetAsync(LibraryDataKind.RemoteImages, Token); + + // The picture is derived; the performer is the thing it was a picture of. + (await service.GetLabelsAsync(Token)).ShouldHaveSingleItem().ImagePath.ShouldBeNull(); + } + + [Fact] + public async Task Usage_counts_labels_by_kind_without_pretending_they_are_a_share_of_anything() + { + var item = FullyIndexed(); + _repository.Seed(item); + + var service = CreateService(); + await service.AttachLabelAsync(item.Id, "драма", LabelKind.Tag, Token); + await service.AttachLabelAsync(item.Id, "нуар", LabelKind.Tag, Token); + await service.AttachLabelAsync(item.Id, "Актёр", LabelKind.Performer, Token); + + var usage = (await service.GetDataUsageAsync(Token)).ToDictionary(entry => entry.Kind); + + usage[LibraryDataKind.Tags].Present.ShouldBe(2); + usage[LibraryDataKind.Performers].Present.ShouldBe(1); + + // No total: "2 из 2" would only invite the reader to look for the missing ones. + usage[LibraryDataKind.Tags].Total.ShouldBeNull(); + } + [Fact] public async Task Usage_says_how_far_each_kind_has_got_through_the_library() { @@ -294,7 +369,7 @@ public sealed class LibraryServiceTests var usage = (await CreateService().GetDataUsageAsync(Token)).ToDictionary(entry => entry.Kind); usage[LibraryDataKind.PerceptualHashes].ShouldSatisfyAllConditions( - entry => entry.Videos.ShouldBe(2), + entry => entry.Total.ShouldBe(2), entry => entry.Present.ShouldBe(1)); // Only the caches that are files on disk have a size to report.