diff --git a/README.md b/README.md
index d2c9dad..695f3ce 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,9 @@
- Анимированное превью: наведите курсор на карточку — вместо постера прокручиваются
кадры, снятые по всей длительности.
- Виртуализированная сетка карточек, ленивая загрузка превью, поиск и сортировка.
+- Вкладки: видео, теги, актёры, студии, коллекции. В каждой — свой поиск и сортировка
+ (по названию или по частоте); клик по сущности показывает её видео в сетке.
+- Поиск в сетке идёт и по меткам, так что имя актёра можно набрать прямо в строке поиска.
- Настройки — боковой панелью в том же окне (сетка сдвигается, а не перекрывается): папки
библиотеки с удалением, параметры превью и сканирования, тема. Всё пишется
в `settings.json` и подхватывается без перезапуска.
@@ -20,6 +23,8 @@
- Источники метаданных: список GraphQL-эндпойнтов (название, адрес, API-ключ) со схемой
stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается
списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия.
+- Вкладка «Метаданные» — тот же поиск сразу по всей библиотеке, с прогрессом, остановкой
+ и списком найденного. По желанию однозначные совпадения применяются на месте.
- Светлая, тёмная и системная темы; выбор запоминается.
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход —
@@ -138,10 +143,32 @@ dotnet test
Появление актёров и студий это подтвердило: два новых значения перечисления, ноль новых
таблиц. Уникальность — по нормализованному имени в паре с видом, так что «Комедия» и
«комедия» не разойдутся, а тег и студия с одним именем сосуществуют.
+- **Вкладки — колонки одного макета, а не `TabControl`.** Страницы, между которыми они
+ переключают, соседствуют с панелью настроек и страницей плеера в одной сетке, а `TabControl`
+ захотел бы владеть этой компоновкой целиком. Четыре вкладки сущностей делят одну панель:
+ различается только вид метки, и четыре почти одинаковых разметки разошлись бы при первой же
+ правке.
+- **Метки лежат на карточке, а не запрашиваются.** Отбор по тегу, актёру или студии — это
+ предикат, который DynamicData прогоняет по каждой карточке в фоновом потоке; ходить оттуда
+ в базу значило бы запрос на карточку. Поэтому `GetLibraryAsync` грузит видео вместе с
+ метками (`AsSplitQuery` — иначе каждая строка видео вернулась бы по разу на метку), а
+ `ApplyLabels` намеренно отделён от `Apply`: сканирование грузит видео без меток, и пустой
+ список там означает «не загружены», а не «их нет».
+ Списки сущностей пересобираются целиком, без второй цепочки DynamicData: меток сотни там,
+ где видео тысячи, и машинерия обошлась бы дороже, чем экономит. Количества считает база
+ (`LabelSummary`), а не загрузка связей ради `Count`.
- **Метаданные — только по кнопке.** Никакой фоновой синхронизации: обращение к чужому
серверу по поводу файлов пользователя происходит тогда, когда он нажал «Найти метаданные»,
и больше никогда. Уходит один отпечаток — 16 шестнадцатеричных цифр; ни имён файлов, ни
самих файлов.
+ Прогон по всей библиотеке — отдельная страница, а не фоновая задача: он обращается к чужим
+ серверам сотни раз подряд, и это должно быть там, где пользователь на это смотрит и может
+ остановить. Между запросами есть пауза (`RequestDelayMilliseconds`, по умолчанию 250 мс) —
+ тысяча запросов залпом получает от публичного инстанса не ответы, а лимит. Источник,
+ который упал, выбывает из прогона после первой же ошибки: отвергнутый ключ падает на каждом
+ видео, и тысяча одинаковых строк была бы всей страницей.
+ «Применять однозначные сразу» по умолчанию выключено, а два кандидата не применяются никогда
+ — расхождение источников это ровно тот случай, ради которого страницу и смотрят.
Источники опрашиваются по очереди и независимо: упавший попадает в список «не ответили»,
но не прячет то, что нашли остальные. GraphQL отвечает двухсотым и массивом `errors`,
поэтому он разбирается явно — иначе неверный ключ читался бы как «источник ничего не знает».
diff --git a/src/PLib.Application/Abstractions/ILabelRepository.cs b/src/PLib.Application/Abstractions/ILabelRepository.cs
index c98e62f..74265b5 100644
--- a/src/PLib.Application/Abstractions/ILabelRepository.cs
+++ b/src/PLib.Application/Abstractions/ILabelRepository.cs
@@ -1,12 +1,20 @@
+using PLib.Application.Library;
using PLib.Domain.Videos;
namespace PLib.Application.Abstractions;
-/// Persistence boundary for tags and collections.
+/// Persistence boundary for tags, collections, performers and studios.
public interface ILabelRepository
{
Task> GetAllAsync(CancellationToken cancellationToken = default);
+ ///
+ /// Every label with how many videos carry it. Counted by the database rather than by
+ /// loading the join: the browsing tabs need the number for every label at once, and
+ /// materialising the relation to count it is the classic way to make that page crawl.
+ ///
+ Task> GetSummariesAsync(CancellationToken cancellationToken = default);
+
/// Finds a label by kind and name, ignoring case and surrounding space.
Task FindAsync(LabelKind kind, string name, CancellationToken cancellationToken = default);
diff --git a/src/PLib.Application/Abstractions/IVideoRepository.cs b/src/PLib.Application/Abstractions/IVideoRepository.cs
index fbc352e..9ad75b5 100644
--- a/src/PLib.Application/Abstractions/IVideoRepository.cs
+++ b/src/PLib.Application/Abstractions/IVideoRepository.cs
@@ -10,6 +10,12 @@ public interface IVideoRepository
{
Task> GetAllAsync(CancellationToken cancellationToken = default);
+ ///
+ /// Every video with its labels loaded. Kept apart from because
+ /// the scan reconciles thousands of rows against disk and has no use for the join.
+ ///
+ Task> GetAllWithLabelsAsync(CancellationToken cancellationToken = default);
+
Task FindByPathAsync(string fullPath, CancellationToken cancellationToken = default);
/// Loads one video together with the labels attached to it.
diff --git a/src/PLib.Application/Library/ILibraryService.cs b/src/PLib.Application/Library/ILibraryService.cs
index 89cc1d3..a3c4888 100644
--- a/src/PLib.Application/Library/ILibraryService.cs
+++ b/src/PLib.Application/Library/ILibraryService.cs
@@ -51,6 +51,9 @@ public interface ILibraryService
/// Every tag and collection in the library, alphabetically.
Task> GetLabelsAsync(CancellationToken cancellationToken = default);
+ /// Every label with its video count, for the browsing tabs.
+ Task> GetLabelSummariesAsync(CancellationToken cancellationToken = default);
+
///
/// Attaches a label to a video, creating it if this is the first time the name is used.
/// Returns the label, whether it was new or not.
@@ -80,4 +83,12 @@ public interface ILibraryService
Guid videoId,
VideoMetadataMatch match,
CancellationToken cancellationToken = default);
+
+ ///
+ /// Asks the sources about every fingerprinted video in the library, streaming what it
+ /// finds. Like the single lookup, it only ever runs because the user started it.
+ ///
+ IAsyncEnumerable ScanMetadataAsync(
+ MetadataScanRequest request,
+ CancellationToken cancellationToken = default);
}
diff --git a/src/PLib.Application/Library/LabelSummary.cs b/src/PLib.Application/Library/LabelSummary.cs
new file mode 100644
index 0000000..40ee1f0
--- /dev/null
+++ b/src/PLib.Application/Library/LabelSummary.cs
@@ -0,0 +1,14 @@
+using PLib.Domain.Videos;
+
+namespace PLib.Application.Library;
+
+///
+/// A label as the browsing tabs need it: what it is called, what kind it is, and how much of
+/// the library it covers.
+///
+///
+/// Not the entity. A list of a thousand tags exists to be looked at and clicked, and loading
+/// the videos behind each one to arrive at a number is the difference between a page that
+/// opens and a page that does not.
+///
+public sealed record LabelSummary(Guid Id, string Name, LabelKind Kind, int VideoCount);
diff --git a/src/PLib.Application/Library/LibraryService.cs b/src/PLib.Application/Library/LibraryService.cs
index d57f2be..fba34ae 100644
--- a/src/PLib.Application/Library/LibraryService.cs
+++ b/src/PLib.Application/Library/LibraryService.cs
@@ -29,7 +29,9 @@ public sealed class LibraryService(
public async Task> GetLibraryAsync(CancellationToken cancellationToken = default)
{
- var items = await repository.GetAllAsync(cancellationToken);
+ // With labels: the grid filters by tag, performer and studio, and asking the database
+ // again for each card as the user clicks around would be a query per card.
+ var items = await repository.GetAllWithLabelsAsync(cancellationToken);
return [.. items.OrderByDescending(x => x.AddedAt)];
}
@@ -110,6 +112,13 @@ public sealed class LibraryService(
return [.. all.OrderBy(label => label.Name, StringComparer.CurrentCultureIgnoreCase)];
}
+ public async Task> GetLabelSummariesAsync(
+ CancellationToken cancellationToken = default)
+ {
+ var summaries = await labels.GetSummariesAsync(cancellationToken);
+ return [.. summaries.OrderBy(summary => summary.Name, StringComparer.CurrentCultureIgnoreCase)];
+ }
+
public async Task AttachLabelAsync(
Guid videoId,
string name,
@@ -191,6 +200,102 @@ public sealed class LibraryService(
return new MetadataLookupResult(HasPerceptualHash: true, matches, failures);
}
+ public async IAsyncEnumerable ScanMetadataAsync(
+ MetadataScanRequest request,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ // Captured once: the run is long, and a source appearing halfway through would make
+ // the totals describe two different questions.
+ var sources = metadataOptions.CurrentValue.Sources.Where(source => source.IsUsable).ToList();
+ var pause = TimeSpan.FromMilliseconds(metadataOptions.CurrentValue.RequestDelayMilliseconds);
+
+ var candidates = (await repository.GetAllAsync(cancellationToken))
+ .Where(video => video.PerceptualHash is not null)
+ .Where(video => !request.OnlyWithoutDescription || video.Description is null)
+ .OrderBy(video => video.Title, StringComparer.CurrentCultureIgnoreCase)
+ .ToList();
+
+ // Reference identity, because a name is free text and two sources may share one.
+ var abandoned = new HashSet();
+
+ var processed = 0;
+ var matched = 0;
+ var applied = 0;
+
+ foreach (var video in candidates)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var found = new List();
+
+ foreach (var source in sources.Where(source => !abandoned.Contains(source)))
+ {
+ if (pause > TimeSpan.Zero)
+ {
+ await Task.Delay(pause, cancellationToken);
+ }
+
+ // The failure is captured rather than handled here: a catch block cannot
+ // yield, and the caller has to hear about it.
+ Exception? failure = null;
+
+ try
+ {
+ found.AddRange(await metadataProvider.FindByPerceptualHashAsync(
+ source,
+ video.PerceptualHash!.Value,
+ cancellationToken));
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ failure = ex;
+ }
+
+ if (failure is not null)
+ {
+ // Dropped for the rest of the run: a rejected key fails on every video,
+ // and a thousand identical lines would bury everything else.
+ abandoned.Add(source);
+ logger.LogWarning(failure, "Metadata source {Source} dropped out of the run", source.Name);
+ yield return new MetadataScanEvent.SourceAbandoned(source.Name, failure.Message);
+ }
+ }
+
+ processed++;
+
+ if (found.Count > 0)
+ {
+ matched++;
+
+ var unambiguous = request.ApplyUnambiguous && found.Count == 1;
+
+ if (unambiguous)
+ {
+ await ApplyMetadataAsync(video.Id, found[0], cancellationToken);
+ applied++;
+ }
+
+ yield return new MetadataScanEvent.Matched(video.Id, video.Title, found, unambiguous);
+ }
+
+ yield return new MetadataScanEvent.Progress(processed, candidates.Count);
+ }
+
+ logger.LogInformation(
+ "Metadata run finished: {Processed} videos, {Matched} matched, {Applied} applied",
+ processed,
+ matched,
+ applied);
+
+ yield return new MetadataScanEvent.Completed(processed, matched, applied);
+ }
+
public async Task ApplyMetadataAsync(
Guid videoId,
VideoMetadataMatch match,
diff --git a/src/PLib.Application/Metadata/MetadataOptions.cs b/src/PLib.Application/Metadata/MetadataOptions.cs
index 4958645..7e80668 100644
--- a/src/PLib.Application/Metadata/MetadataOptions.cs
+++ b/src/PLib.Application/Metadata/MetadataOptions.cs
@@ -12,6 +12,14 @@ public sealed class MetadataOptions
/// remote service about the user's files is something they have to ask for.
///
public IList Sources { get; init; } = [];
+
+ ///
+ /// How long to wait between requests during a library-wide run. A single lookup is one
+ /// request and needs no pause; a run over a thousand videos is a thousand, and public
+ /// instances answer that with a rate limit if it arrives all at once.
+ ///
+ [Range(0, 10_000)]
+ public int RequestDelayMilliseconds { get; init; } = 250;
}
/// One GraphQL endpoint that can be asked about a video.
diff --git a/src/PLib.Application/Metadata/MetadataScanEvent.cs b/src/PLib.Application/Metadata/MetadataScanEvent.cs
new file mode 100644
index 0000000..3005434
--- /dev/null
+++ b/src/PLib.Application/Metadata/MetadataScanEvent.cs
@@ -0,0 +1,44 @@
+namespace PLib.Application.Metadata;
+
+/// What a library-wide metadata run should cover and how much it may decide alone.
+///
+/// Skip videos that already carry a description. A second run over a large library is usually
+/// meant to fill the gaps, not to ask again about everything that already worked.
+///
+///
+/// Apply a result without asking when exactly one source returned exactly one candidate.
+/// Off by default: a fingerprint match is a proposal, and several proposals disagreeing is
+/// precisely the case a human has to look at.
+///
+public sealed record MetadataScanRequest(bool OnlyWithoutDescription = true, bool ApplyUnambiguous = false);
+
+/// Something that happened during a library-wide metadata run.
+public abstract record MetadataScanEvent
+{
+ private MetadataScanEvent()
+ {
+ }
+
+ /// How far the run has got.
+ public sealed record Progress(int Processed, int Total) : MetadataScanEvent;
+
+ /// A video the sources had something to say about.
+ /// True when it was unambiguous and the run wrote it straight away.
+ public sealed record Matched(
+ Guid VideoId,
+ string VideoTitle,
+ IReadOnlyList Matches,
+ bool Applied) : MetadataScanEvent;
+
+ ///
+ /// A source dropped out of the run. Reported once and then left alone: a bad key fails on
+ /// every video, and a thousand identical lines would bury everything else.
+ ///
+ public sealed record SourceAbandoned(string SourceName, string Reason) : MetadataScanEvent;
+
+ ///
+ /// Named around the count rather than as "Matched", which is already the name of the
+ /// event above it.
+ ///
+ public sealed record Completed(int Processed, int WithMatches, int Applied) : MetadataScanEvent;
+}
diff --git a/src/PLib.Desktop/Themes/LibraryStyles.axaml b/src/PLib.Desktop/Themes/LibraryStyles.axaml
index f593c6d..434fc27 100644
--- a/src/PLib.Desktop/Themes/LibraryStyles.axaml
+++ b/src/PLib.Desktop/Themes/LibraryStyles.axaml
@@ -86,6 +86,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+