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.

This commit is contained in:
Leonid Pershin
2026-08-10 10:50:03 +03:00
parent b82b0b4555
commit 4539a97d16
8 changed files with 216 additions and 21 deletions
+14 -8
View File
@@ -21,7 +21,8 @@
библиотеки с удалением, параметры превью и сканирования, тема. Всё пишется библиотеки с удалением, параметры превью и сканирования, тема. Всё пишется
в `settings.json` и подхватывается без перезапуска. в `settings.json` и подхватывается без перезапуска.
- Очистка собранных данных по видам — постеры, анимированные превью, отпечатки, технические - Очистка собранных данных по видам — постеры, анимированные превью, отпечатки, технические
метаданные — каждый со своей кнопкой и текущим объёмом. метаданные, изображения меток, теги, актёры, студии, коллекции — каждый со своей кнопкой
и текущим объёмом, плюс «очистить всё».
- Источники метаданных: список GraphQL-эндпойнтов (название, адрес, API-ключ) со схемой - Источники метаданных: список GraphQL-эндпойнтов (название, адрес, API-ключ) со схемой
stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается
списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия. списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия.
@@ -238,13 +239,18 @@ dotnet test
Постеры и анимации различаются лишь тем, что просят у ffmpeg, а хозяйство у них одно, и Постеры и анимации различаются лишь тем, что просят у ffmpeg, а хозяйство у них одно, и
описано оно один раз: иначе размер кэша в настройках начал бы врать в тот же день, когда описано оно один раз: иначе размер кэша в настройках начал бы врать в тот же день, когда
появился второй вид файлов. появился второй вид файлов.
- **Очистка — по видам, и только того, что пересобирается.** `LibraryDataKind` перечисляет - **Очистка — по видам, и вид говорит, чем платишь.** Первые четыре пункта
ровно то, что выводится из самих файлов: постеры, анимации, отпечатки, техметаданные. `LibraryDataKind` выводятся из самих файлов: постеры, анимации, отпечатки,
Цена очистки любого из них — время, а не информация, поэтому кнопка не спрашивает техметаданные. Их очистка стоит времени, а не информации.
подтверждения. Названия, теги, коллекции и прогресс просмотра в этот список сознательно Метки поначалу в список не входили — как пользовательские данные. Это перестало быть
не входят: их не вернёт никакое пересканирование, так что соседство с ними в одном ряду правдой, когда теги, актёров и студии начали приезжать из источников метаданных пачками:
кнопок было бы ловушкой. Ссылки забываются раньше, чем удаляются файлы, — прерывание «очистить всё», оставляющее их, просто не делает того, что обещает. Теперь каждый вид
в обратном порядке оставило бы библиотеку с путями в никуда. метки — своя кнопка, и подпись под каждой честно говорит, чем именно она восстановится:
повторным применением совпадения, или ничем, как в случае коллекций.
Ссылки забываются раньше, чем удаляются файлы, — прерывание в обратном порядке оставило
бы библиотеку с путями в никуда. А метки снимаются с загруженных видео явно, а не
каскадом в БД: каскад вычистил бы строки связи и оставил каждое видео в памяти всё ещё
держащим метку — на диске верно, на экране нет, пока что-нибудь не перечитает.
## Данные ## Данные
@@ -32,12 +32,32 @@ public enum LibraryDataKind
/// </summary> /// </summary>
RemoteImages = 1 << 4, RemoteImages = 1 << 4,
All = Thumbnails | AnimatedPreviews | PerceptualHashes | TechnicalMetadata | RemoteImages, /// <summary>Tags, however they got there.</summary>
Tags = 1 << 5,
/// <summary>Performers, along with their pictures.</summary>
Performers = 1 << 6,
/// <summary>Studios, along with their logos.</summary>
Studios = 1 << 7,
/// <summary>
/// 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.
/// </summary>
Collections = 1 << 8,
All = Thumbnails | AnimatedPreviews | PerceptualHashes | TechnicalMetadata | RemoteImages |
Tags | Performers | Studios | Collections,
} }
/// <summary>What one kind of derived data currently costs.</summary> /// <summary>What one kind of derived data currently costs.</summary>
/// <param name="Kind">Which kind this describes.</param> /// <param name="Kind">Which kind this describes.</param>
/// <param name="Videos">How many videos are in the library altogether.</param> /// <param name="Present">How many of this kind exist.</param>
/// <param name="Present">For how many of them this kind of data exists.</param> /// <param name="Total">
/// What <paramref name="Present"/> 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.
/// </param>
/// <param name="Bytes">Disk space taken, or zero for kinds that live only in the database.</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); public sealed record LibraryDataUsage(LibraryDataKind Kind, int Present, int? Total, long Bytes);
+70 -6
View File
@@ -412,23 +412,59 @@ public sealed class LibraryService(
var previewBytes = await previewGenerator.GetCacheSizeInBytesAsync(cancellationToken); var previewBytes = await previewGenerator.GetCacheSizeInBytesAsync(cancellationToken);
var imageBytes = await remoteImages.GetCacheSizeInBytesAsync(cancellationToken); var imageBytes = await remoteImages.GetCacheSizeInBytesAsync(cancellationToken);
int Labels(LabelKind kind) => allLabels.Count(label => label.Kind == kind);
return return
[ [
new(LibraryDataKind.Thumbnails, items.Count, items.Count(x => x.ThumbnailPath is not null), thumbnailBytes), new(LibraryDataKind.Thumbnails, items.Count(x => x.ThumbnailPath is not null), items.Count, thumbnailBytes),
new(LibraryDataKind.AnimatedPreviews, items.Count, items.Count(x => x.PreviewPath is not null), previewBytes), new(LibraryDataKind.AnimatedPreviews, items.Count(x => x.PreviewPath is not null), items.Count, previewBytes),
new(LibraryDataKind.PerceptualHashes, items.Count, items.Count(x => x.PerceptualHash is not null), 0), new(LibraryDataKind.PerceptualHashes, items.Count(x => x.PerceptualHash is not null), items.Count, 0),
new(LibraryDataKind.TechnicalMetadata, items.Count, items.Count(x => x.Duration is not null), 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 // 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. // picture, and "12 из 4000 видео" would read as a failure rather than a fact.
new( new(
LibraryDataKind.RemoteImages, LibraryDataKind.RemoteImages,
allLabels.Count,
allLabels.Count(x => x.ImagePath is not null), allLabels.Count(x => x.ImagePath is not null),
allLabels.Count,
imageBytes), 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),
]; ];
} }
/// <summary>Which label kinds a request covers, if any.</summary>
private static IReadOnlyList<LabelKind> LabelKindsOf(LibraryDataKind kinds)
{
var result = new List<LabelKind>(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) public async Task ResetAsync(LibraryDataKind kinds, CancellationToken cancellationToken = default)
{ {
if (kinds == LibraryDataKind.None) if (kinds == LibraryDataKind.None)
@@ -436,10 +472,24 @@ public sealed class LibraryService(
return; 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 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)) if (kinds.HasFlag(LibraryDataKind.Thumbnails))
{ {
item.DetachThumbnail(); 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, // 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 // 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. // a scan notices. This order leaves at worst some orphans, which the purge eats.
@@ -39,7 +39,9 @@ public sealed partial class LibraryDataViewModel : ReactiveObject
/// </summary> /// </summary>
public void Apply(LibraryDataUsage usage) 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; UsageText = usage.Bytes > 0 ? $"{DisplayText.FileSize(usage.Bytes)} · {coverage}" : coverage;
} }
} }
@@ -750,6 +750,10 @@ public sealed partial class MainWindowViewModel : ViewModelBase
if (outcome.RescanRequired) if (outcome.RescanRequired)
{ {
await ScanCommand.Execute(); 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();
} }
} }
@@ -101,6 +101,30 @@ public sealed partial class SettingsViewModel : ViewModelBase
"Фото актёров и логотипы студий. Скачиваются из источника метаданных, а не из файлов, поэтому сканирование их не вернёт — только повторное применение совпадения.", "Фото актёров и логотипы студий. Скачиваются из источника метаданных, а не из файлов, поэтому сканирование их не вернёт — только повторное применение совпадения.",
ClearAsync, ClearAsync,
idle), 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); ClearAllCommand = ReactiveCommand.CreateFromTask(() => ClearAsync(LibraryDataKind.All), idle);
+1 -1
View File
@@ -274,7 +274,7 @@
Content="Очистить всё" /> Content="Очистить всё" />
<TextBlock Classes="hint" <TextBlock Classes="hint"
Text="Всё перечисленное собирается из самих файлов, поэтому очистка стоит только времени: недостающее досчитается при следующем сканировании. Названия, теги, коллекции и прогресс просмотра здесь не трогаются — их пересканирование не вернёт." /> Text="Первые четыре пункта собираются из самих файлов: очистка стоит только времени, недостающее досчитается при следующем сканировании. Остальное приходит из источников метаданных или от вас — сканирование его не вернёт, а коллекции не вернёт ничто. «Очистить всё» берёт весь список, включая их." />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -283,6 +283,81 @@ public sealed class LibraryServiceTests
item.IsIndexed.ShouldBeFalse(); 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] [Fact]
public async Task Usage_says_how_far_each_kind_has_got_through_the_library() 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); var usage = (await CreateService().GetDataUsageAsync(Token)).ToDictionary(entry => entry.Kind);
usage[LibraryDataKind.PerceptualHashes].ShouldSatisfyAllConditions( usage[LibraryDataKind.PerceptualHashes].ShouldSatisfyAllConditions(
entry => entry.Videos.ShouldBe(2), entry => entry.Total.ShouldBe(2),
entry => entry.Present.ShouldBe(1)); entry => entry.Present.ShouldBe(1));
// Only the caches that are files on disk have a size to report. // Only the caches that are files on disk have a size to report.