From 5acb42d11d8e998e88bdf9fd47f86555713e011b Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 9 Aug 2026 08:58:07 +0300 Subject: [PATCH] Enhance PLib video library manager with new tabbed navigation for entities and metadata management. Introduce sections for videos, tags, actors, studios, collections, and metadata, each with dedicated search and sorting capabilities. Update UI components to reflect these changes, ensuring a cohesive user experience. Revise repository interfaces to support loading video items with labels and summaries for efficient browsing. Update README.md to document new features and usage instructions. --- README.md | 27 ++ .../Abstractions/ILabelRepository.cs | 10 +- .../Abstractions/IVideoRepository.cs | 6 + .../Library/ILibraryService.cs | 11 + src/PLib.Application/Library/LabelSummary.cs | 14 ++ .../Library/LibraryService.cs | 107 +++++++- .../Metadata/MetadataOptions.cs | 8 + .../Metadata/MetadataScanEvent.cs | 44 ++++ src/PLib.Desktop/Themes/LibraryStyles.axaml | 60 +++++ .../ViewModels/LabelSummaryViewModel.cs | 61 +++++ src/PLib.Desktop/ViewModels/LibrarySection.cs | 52 ++++ .../ViewModels/MainWindowViewModel.cs | 231 +++++++++++++++++- .../ViewModels/MetadataScanResultViewModel.cs | 43 ++++ .../ViewModels/MetadataScanViewModel.cs | 192 +++++++++++++++ .../ViewModels/VideoCardViewModel.cs | 35 ++- .../ViewModels/VideoPlayerViewModel.cs | 5 + src/PLib.Desktop/Views/MainWindow.axaml | 127 +++++++++- src/PLib.Desktop/Views/MetadataScanView.axaml | 137 +++++++++++ .../Views/MetadataScanView.axaml.cs | 8 + .../Persistence/EfLabelRepository.cs | 7 + .../Persistence/EfVideoRepository.cs | 10 + .../Library/InMemoryLabelRepository.cs | 5 + .../Library/InMemoryVideoRepository.cs | 4 + tests/PLib.Tests/Library/MetadataMonitor.cs | 3 + tests/PLib.Tests/Library/MetadataScanTests.cs | 161 ++++++++++++ .../ViewModels/VideoCardLabelTests.cs | 84 +++++++ 26 files changed, 1434 insertions(+), 18 deletions(-) create mode 100644 src/PLib.Application/Library/LabelSummary.cs create mode 100644 src/PLib.Application/Metadata/MetadataScanEvent.cs create mode 100644 src/PLib.Desktop/ViewModels/LabelSummaryViewModel.cs create mode 100644 src/PLib.Desktop/ViewModels/LibrarySection.cs create mode 100644 src/PLib.Desktop/ViewModels/MetadataScanResultViewModel.cs create mode 100644 src/PLib.Desktop/ViewModels/MetadataScanViewModel.cs create mode 100644 src/PLib.Desktop/Views/MetadataScanView.axaml create mode 100644 src/PLib.Desktop/Views/MetadataScanView.axaml.cs create mode 100644 tests/PLib.Tests/Library/MetadataScanTests.cs create mode 100644 tests/PLib.Tests/ViewModels/VideoCardLabelTests.cs 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 @@ + + + + + + + + + + + + + + + + + + + +