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.