From 4539a97d16d1ab4fde37670abdb6f0f4d7146f3e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 10 Aug 2026 10:50:03 +0300 Subject: [PATCH] Enhance metadata management in PLib video library manager by introducing new label handling features. Update LibraryDataKind to include Tags, Performers, Studios, and Collections, allowing for more granular data clearing options. Revise LibraryService to support label management during data resets, ensuring proper handling of user-added metadata. Update README.md to reflect these changes and clarify the implications of data clearing operations. --- README.md | 22 ++++-- .../Library/LibraryDataKind.cs | 28 ++++++- .../Library/LibraryService.cs | 76 ++++++++++++++++-- .../ViewModels/LibraryDataViewModel.cs | 4 +- .../ViewModels/MainWindowViewModel.cs | 4 + .../ViewModels/SettingsViewModel.cs | 24 ++++++ src/PLib.Desktop/Views/SettingsView.axaml | 2 +- .../PLib.Tests/Library/LibraryServiceTests.cs | 77 ++++++++++++++++++- 8 files changed, 216 insertions(+), 21 deletions(-) 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.