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.

This commit is contained in:
Leonid Pershin
2026-08-09 08:58:07 +03:00
parent 61d01d1970
commit 5acb42d11d
26 changed files with 1434 additions and 18 deletions
+27
View File
@@ -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`,
поэтому он разбирается явно — иначе неверный ключ читался бы как «источник ничего не знает».
@@ -1,12 +1,20 @@
using PLib.Application.Library;
using PLib.Domain.Videos;
namespace PLib.Application.Abstractions;
/// <summary>Persistence boundary for tags and collections.</summary>
/// <summary>Persistence boundary for tags, collections, performers and studios.</summary>
public interface ILabelRepository
{
Task<IReadOnlyList<LibraryLabel>> GetAllAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 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.
/// </summary>
Task<IReadOnlyList<LabelSummary>> GetSummariesAsync(CancellationToken cancellationToken = default);
/// <summary>Finds a label by kind and name, ignoring case and surrounding space.</summary>
Task<LibraryLabel?> FindAsync(LabelKind kind, string name, CancellationToken cancellationToken = default);
@@ -10,6 +10,12 @@ public interface IVideoRepository
{
Task<IReadOnlyList<VideoItem>> GetAllAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Every video with its labels loaded. Kept apart from <see cref="GetAllAsync"/> because
/// the scan reconciles thousands of rows against disk and has no use for the join.
/// </summary>
Task<IReadOnlyList<VideoItem>> GetAllWithLabelsAsync(CancellationToken cancellationToken = default);
Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default);
/// <summary>Loads one video together with the labels attached to it.</summary>
@@ -51,6 +51,9 @@ public interface ILibraryService
/// <summary>Every tag and collection in the library, alphabetically.</summary>
Task<IReadOnlyList<LibraryLabel>> GetLabelsAsync(CancellationToken cancellationToken = default);
/// <summary>Every label with its video count, for the browsing tabs.</summary>
Task<IReadOnlyList<LabelSummary>> GetLabelSummariesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 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);
/// <summary>
/// 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.
/// </summary>
IAsyncEnumerable<MetadataScanEvent> ScanMetadataAsync(
MetadataScanRequest request,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,14 @@
using PLib.Domain.Videos;
namespace PLib.Application.Library;
/// <summary>
/// A label as the browsing tabs need it: what it is called, what kind it is, and how much of
/// the library it covers.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed record LabelSummary(Guid Id, string Name, LabelKind Kind, int VideoCount);
+106 -1
View File
@@ -29,7 +29,9 @@ public sealed class LibraryService(
public async Task<IReadOnlyList<VideoItem>> 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<IReadOnlyList<LabelSummary>> GetLabelSummariesAsync(
CancellationToken cancellationToken = default)
{
var summaries = await labels.GetSummariesAsync(cancellationToken);
return [.. summaries.OrderBy(summary => summary.Name, StringComparer.CurrentCultureIgnoreCase)];
}
public async Task<LibraryLabel> AttachLabelAsync(
Guid videoId,
string name,
@@ -191,6 +200,102 @@ public sealed class LibraryService(
return new MetadataLookupResult(HasPerceptualHash: true, matches, failures);
}
public async IAsyncEnumerable<MetadataScanEvent> 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<MetadataSourceOptions>();
var processed = 0;
var matched = 0;
var applied = 0;
foreach (var video in candidates)
{
cancellationToken.ThrowIfCancellationRequested();
var found = new List<VideoMetadataMatch>();
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,
@@ -12,6 +12,14 @@ public sealed class MetadataOptions
/// remote service about the user's files is something they have to ask for.
/// </summary>
public IList<MetadataSourceOptions> Sources { get; init; } = [];
/// <summary>
/// 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.
/// </summary>
[Range(0, 10_000)]
public int RequestDelayMilliseconds { get; init; } = 250;
}
/// <summary>One GraphQL endpoint that can be asked about a video.</summary>
@@ -0,0 +1,44 @@
namespace PLib.Application.Metadata;
/// <summary>What a library-wide metadata run should cover and how much it may decide alone.</summary>
/// <param name="OnlyWithoutDescription">
/// 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.
/// </param>
/// <param name="ApplyUnambiguous">
/// 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.
/// </param>
public sealed record MetadataScanRequest(bool OnlyWithoutDescription = true, bool ApplyUnambiguous = false);
/// <summary>Something that happened during a library-wide metadata run.</summary>
public abstract record MetadataScanEvent
{
private MetadataScanEvent()
{
}
/// <summary>How far the run has got.</summary>
public sealed record Progress(int Processed, int Total) : MetadataScanEvent;
/// <summary>A video the sources had something to say about.</summary>
/// <param name="Applied">True when it was unambiguous and the run wrote it straight away.</param>
public sealed record Matched(
Guid VideoId,
string VideoTitle,
IReadOnlyList<VideoMetadataMatch> Matches,
bool Applied) : MetadataScanEvent;
/// <summary>
/// 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.
/// </summary>
public sealed record SourceAbandoned(string SourceName, string Reason) : MetadataScanEvent;
/// <param name="WithMatches">
/// Named around the count rather than as "Matched", which is already the name of the
/// event above it.
/// </param>
public sealed record Completed(int Processed, int WithMatches, int Applied) : MetadataScanEvent;
}
@@ -86,6 +86,66 @@
<Setter Property="IsPlaying" Value="True" />
</Style>
<!-- ========================== Navigation ========================== -->
<!--
Tabs are buttons rather than a TabControl: the pages they switch between are columns of a
layout that also holds the settings panel and the media page, and a TabControl would want
to own that arrangement.
-->
<Style Selector="Button.sectionTab">
<Setter Property="Padding" Value="12,6" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
</Style>
<Style Selector="Button.sectionTab:pointerover">
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
</Style>
<Style Selector="Button.sectionTab.active">
<Setter Property="Background" Value="{DynamicResource AccentSoftBrush}" />
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!-- A row of controls that belongs to the page below it rather than to the window. -->
<Style Selector="Border.filterBar">
<Setter Property="Padding" Value="20,10" />
<Setter Property="BorderBrush" Value="{DynamicResource SurfaceBorderBrush}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
</Style>
<!--
Also used by the task panel and the filter bar, which had been asking for it since before
it existed — the class was on the buttons with nothing behind it.
-->
<Style Selector="Button.transport">
<Setter Property="Padding" Value="8" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
</Style>
<!-- One tag, performer, studio or collection in a browsing tab. -->
<Style Selector="Button.entity">
<Setter Property="Padding" Value="12,10" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="10" />
</Style>
<Style Selector="Button.entity:pointerover">
<Setter Property="BorderBrush" Value="{DynamicResource CardHoverBorderBrush}" />
</Style>
<!-- ============================ Text ============================ -->
<Style Selector="TextBlock.cardTitle">
@@ -0,0 +1,61 @@
using System.Globalization;
using PLib.Application.Library;
using PLib.Domain.Videos;
using ReactiveUI;
using RxVoid = ReactiveUI.Primitives.RxVoid;
namespace PLib.Desktop.ViewModels;
/// <summary>One tag, performer, studio or collection in a browsing tab.</summary>
public sealed class LabelSummaryViewModel
{
public LabelSummaryViewModel(LabelSummary summary, Action<LabelSummaryViewModel> open)
{
ArgumentNullException.ThrowIfNull(summary);
Id = summary.Id;
Name = summary.Name;
Kind = summary.Kind;
VideoCount = summary.VideoCount;
CountText = VideoCount.ToString(CultureInfo.CurrentCulture);
OpenCommand = ReactiveCommand.Create(() => open(this));
}
public Guid Id { get; }
public string Name { get; }
public LabelKind Kind { get; }
public int VideoCount { get; }
public string CountText { get; }
/// <summary>Narrows the video grid down to this label and switches to it.</summary>
public ReactiveCommand<RxVoid, RxVoid> OpenCommand { get; }
public bool Matches(string term) => Name.Contains(term, StringComparison.CurrentCultureIgnoreCase);
}
/// <summary>An order for the entity lists, and how to read it.</summary>
public sealed record EntitySortOption(string Label, Comparison<LabelSummaryViewModel> Compare)
{
public static IReadOnlyList<EntitySortOption> All { get; } =
[
new("Сначала частые", (x, y) =>
{
var byCount = y.VideoCount.CompareTo(x.VideoCount);
return byCount != 0 ? byCount : Name(x, y);
}),
new("По названию", Name),
];
/// <summary>
/// Culture-aware and case-insensitive, so Cyrillic and Latin names land where a reader
/// expects rather than in ordinal order.
/// </summary>
private static int Name(LabelSummaryViewModel x, LabelSummaryViewModel y) =>
string.Compare(x.Name, y.Name, StringComparison.CurrentCultureIgnoreCase);
}
@@ -0,0 +1,52 @@
using PLib.Domain.Videos;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
using RxVoid = ReactiveUI.Primitives.RxVoid;
namespace PLib.Desktop.ViewModels;
/// <summary>Which of the window's pages is on screen.</summary>
public enum LibrarySection
{
Videos,
Tags,
Performers,
Studios,
Collections,
/// <summary>The library-wide metadata run.</summary>
Metadata,
}
/// <summary>One tab in the window's navigation strip.</summary>
public sealed partial class SectionTabViewModel : ReactiveObject
{
public SectionTabViewModel(LibrarySection section, string title, Action<LibrarySection> select)
{
Section = section;
Title = title;
SelectCommand = ReactiveCommand.Create(() => select(section));
}
public LibrarySection Section { get; }
public string Title { get; }
[Reactive]
public partial bool IsSelected { get; set; }
public ReactiveCommand<RxVoid, RxVoid> SelectCommand { get; }
}
/// <summary>Maps the four label tabs onto the label kind each of them lists.</summary>
public static class LibrarySectionExtensions
{
public static LabelKind? LabelKind(this LibrarySection section) => section switch
{
LibrarySection.Tags => Domain.Videos.LabelKind.Tag,
LibrarySection.Performers => Domain.Videos.LabelKind.Performer,
LibrarySection.Studios => Domain.Videos.LabelKind.Studio,
LibrarySection.Collections => Domain.Videos.LabelKind.Collection,
_ => null,
};
}
@@ -69,6 +69,13 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly ObservableAsPropertyHelper<bool> _isSettingsOpen;
private readonly ObservableAsPropertyHelper<bool> _isPlayerOpen;
private readonly ObservableAsPropertyHelper<bool> _isVideoFullScreen;
private readonly ObservableAsPropertyHelper<bool> _isVideosSection;
private readonly ObservableAsPropertyHelper<bool> _isEntitySection;
private readonly ObservableAsPropertyHelper<bool> _isMetadataSection;
private readonly ObservableAsPropertyHelper<bool> _isLibraryPromptVisible;
/// <summary>Every label in the library, reloaded when a tab is opened.</summary>
private IReadOnlyList<LabelSummary> _allLabels = [];
public MainWindowViewModel(
IServiceScopeFactory scopeFactory,
@@ -90,6 +97,38 @@ public sealed partial class MainWindowViewModel : ViewModelBase
_logger = logger;
SelectedSort = SortOption.All[0];
SelectedEntitySort = EntitySortOption.All[0];
Sections =
[
new(LibrarySection.Videos, "Видео", Select),
new(LibrarySection.Tags, "Теги", Select),
new(LibrarySection.Performers, "Актёры", Select),
new(LibrarySection.Studios, "Студии", Select),
new(LibrarySection.Collections, "Коллекции", Select),
new(LibrarySection.Metadata, "Метаданные", Select),
];
_isVideosSection = this
.WhenAnyValue(x => x.SelectedSection)
.Select(section => section == LibrarySection.Videos)
.ToProperty(this, x => x.IsVideosSection);
// The four label tabs share one list; only the kind they show differs, so the view
// has one panel rather than four near-identical ones.
_isEntitySection = this
.WhenAnyValue(x => x.SelectedSection)
.Select(section => section.LabelKind() is not null)
.ToProperty(this, x => x.IsEntitySection);
_isMetadataSection = this
.WhenAnyValue(x => x.SelectedSection)
.Select(section => section == LibrarySection.Metadata)
.ToProperty(this, x => x.IsMetadataSection);
ClearLabelFilterCommand = ReactiveCommand.Create(
() => { ActiveLabel = null; },
this.WhenAnyValue(x => x.ActiveLabel).Select(label => label is not null));
InitializeCommand = ReactiveCommand.CreateFromTask(InitializeAsync);
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
@@ -141,10 +180,54 @@ public sealed partial class MainWindowViewModel : ViewModelBase
_isScanning = ScanCommand.IsExecuting.ToProperty(this, x => x.IsScanning);
BuildLibraryView(out _videos, out _isEmpty);
// After the view is built, because it reads the emptiness flag that builds. The
// prompt belongs to the video grid: without the section in the condition it would sit
// over an empty tag list too, inviting the user to add a folder they already have.
_isLibraryPromptVisible = this
.WhenAnyValue(x => x.IsEmpty, x => x.IsVideosSection, (empty, videos) => empty && videos)
.ToProperty(this, x => x.IsLibraryPromptVisible);
ObserveEntityView();
ObserveFolderChanges();
ObserveCommandFailures();
Select(LibrarySection.Videos);
}
/// <summary>The navigation strip; one entry per page.</summary>
public IReadOnlyList<SectionTabViewModel> Sections { get; }
[Reactive]
public partial LibrarySection SelectedSection { get; set; }
public bool IsVideosSection => _isVideosSection.Value;
public bool IsEntitySection => _isEntitySection.Value;
public bool IsMetadataSection => _isMetadataSection.Value;
/// <summary>Tags, performers, studios or collections, depending on the open tab.</summary>
public ObservableCollection<LabelSummaryViewModel> Entities { get; } = [];
public IReadOnlyList<EntitySortOption> EntitySortOptions => EntitySortOption.All;
[Reactive]
public partial EntitySortOption SelectedEntitySort { get; set; }
[Reactive]
public partial string EntitySearchText { get; set; } = string.Empty;
/// <summary>The label the grid is narrowed to, or <c>null</c> when it shows everything.</summary>
[Reactive]
public partial LabelSummaryViewModel? ActiveLabel { get; set; }
public ReactiveCommand<RxVoid, RxVoid> ClearLabelFilterCommand { get; }
/// <summary>The metadata page, built once and kept for the life of the window.</summary>
[Reactive]
public partial MetadataScanViewModel? MetadataScan { get; set; }
/// <summary>The cards actually on screen, in the order the user asked for.</summary>
public ReadOnlyObservableCollection<VideoCardViewModel> Videos => _videos;
@@ -155,6 +238,9 @@ public sealed partial class MainWindowViewModel : ViewModelBase
/// <summary>True when there is nothing to show and no scan is running to change that.</summary>
public bool IsEmpty => _isEmpty.Value;
/// <summary>True when the empty grid should offer to add a folder.</summary>
public bool IsLibraryPromptVisible => _isLibraryPromptVisible.Value;
public bool HasFolders => _options.CurrentValue.Folders.Count > 0;
public ReactiveCommand<RxVoid, RxVoid> InitializeCommand { get; }
@@ -241,9 +327,11 @@ public sealed partial class MainWindowViewModel : ViewModelBase
// Throttle swallows the initial value, and the grid must not start out blank.
.StartWith(SearchText)
.DistinctUntilChanged()
// The duplicates toggle is a second input to the same predicate, so it has to
// re-emit it — the search term alone would leave the grid on the old filter.
// The duplicates toggle and the label filter are further inputs to the same
// predicate, so they have to re-emit it — the search term alone would leave the
// grid on the old filter.
.CombineLatest(this.WhenAnyValue(x => x.ShowingDuplicatesOnly), (term, _) => term)
.CombineLatest(this.WhenAnyValue(x => x.ActiveLabel), (term, _) => term)
.Select(BuildFilter);
var comparerChanged = this
@@ -271,6 +359,137 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private VideoCardViewModel CreateCard(Domain.Videos.VideoItem item) =>
new(item, _shell, OpenVideo);
/// <summary>For the paths that loaded the labels too — everything but the scan.</summary>
private VideoCardViewModel CreateCardWithLabels(Domain.Videos.VideoItem item)
{
var card = CreateCard(item);
card.ApplyLabels(item.Labels);
return card;
}
/// <summary>Points the whole window at one page and loads whatever that page needs.</summary>
private void Select(LibrarySection section)
{
SelectedSection = section;
foreach (var tab in Sections)
{
tab.IsSelected = tab.Section == section;
}
if (section.LabelKind() is not null)
{
// Counts move as labels are attached elsewhere, so the list is read on each visit
// rather than cached for the life of the window.
_ = RefreshEntitiesAsync();
}
if (section == LibrarySection.Metadata)
{
// Built on first visit and kept: the page holds the results of a run that may
// have taken minutes, and switching tabs must not throw them away.
MetadataScan ??= new MetadataScanViewModel(_scopeFactory, RefreshLibraryAsync, _logger);
}
}
/// <summary>Opens the video grid narrowed to one label.</summary>
private void OpenLabel(LabelSummaryViewModel label)
{
ActiveLabel = label;
Select(LibrarySection.Videos);
}
/// <summary>
/// Rebuilds the entity list when the tab, the search term or the order changes. Plain
/// rebuilding rather than a second DynamicData chain: labels number in the hundreds where
/// videos number in the thousands, and the machinery would cost more than it saved.
/// </summary>
private void ObserveEntityView() =>
this.WhenAnyValue(
x => x.EntitySearchText,
x => x.SelectedEntitySort,
x => x.SelectedSection,
(term, sort, section) => RxVoid.Default)
.Throttle(SearchDebounce, TaskPoolScheduler.Default)
.ObserveOn(_uiScheduler)
.Subscribe(_ => RebuildEntities())
.AddTo(Subscriptions);
private void RebuildEntities()
{
Entities.Clear();
if (SelectedSection.LabelKind() is not { } kind)
{
return;
}
var term = EntitySearchText?.Trim();
var rows = _allLabels
.Where(label => label.Kind == kind)
.Select(label => new LabelSummaryViewModel(label, OpenLabel))
.Where(row => string.IsNullOrEmpty(term) || row.Matches(term))
.ToList();
rows.Sort(SelectedEntitySort.Compare);
foreach (var row in rows)
{
Entities.Add(row);
}
}
private async Task RefreshEntitiesAsync()
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
_allLabels = await library.GetLabelSummariesAsync();
RebuildEntities();
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not load the labels");
StatusText = "Не удалось загрузить список — подробности в журнале";
}
}
/// <summary>
/// Re-reads the library after something outside the grid changed it — a metadata run
/// renames videos and attaches labels wholesale. Cards are updated in place rather than
/// replaced, so the scroll position and the loaded poster frames survive.
/// </summary>
private async Task RefreshLibraryAsync()
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
var items = await library.GetLibraryAsync();
_library.Edit(updater =>
{
foreach (var item in items)
{
var existing = updater.Lookup(item.Id);
if (existing.HasValue)
{
existing.Value.Apply(item);
existing.Value.ApplyLabels(item.Labels);
}
else
{
updater.AddOrUpdate(CreateCardWithLabels(item));
}
}
});
await RefreshEntitiesAsync();
}
private void OpenVideo(VideoCardViewModel card)
{
OpenedVideo?.Dispose();
@@ -334,8 +553,13 @@ public sealed partial class MainWindowViewModel : ViewModelBase
var duplicatesOnly = ShowingDuplicatesOnly;
var duplicates = duplicatesOnly ? _duplicates.ToHashSet() : [];
// Read once, into the closure: the predicate runs on a background scheduler for every
// card, and reading a property of this view model from there would be a race.
var labelId = ActiveLabel?.Id;
return card =>
(!duplicatesOnly || duplicates.Contains(card.Id)) &&
(labelId is not { } id || card.HasLabel(id)) &&
(trimmed is null || card.Matches(trimmed));
}
@@ -353,6 +577,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
ToggleThemeCommand.ThrownExceptions,
ToggleDuplicatesCommand.ThrownExceptions,
ToggleTaskPanelCommand.ThrownExceptions,
ClearLabelFilterCommand.ThrownExceptions,
OpenSettingsCommand.ThrownExceptions,
CloseSettingsCommand.ThrownExceptions,
ClosePlayerCommand.ThrownExceptions)
@@ -372,7 +597,7 @@ public sealed partial class MainWindowViewModel : ViewModelBase
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
var items = await library.GetLibraryAsync();
_library.AddOrUpdate(items.Select(item => CreateCard(item)));
_library.AddOrUpdate(items.Select(CreateCardWithLabels));
}
catch (Exception ex)
{
@@ -0,0 +1,43 @@
using System.Collections.ObjectModel;
using PLib.Application.Metadata;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
namespace PLib.Desktop.ViewModels;
/// <summary>One video the library-wide run found candidates for.</summary>
public sealed partial class MetadataScanResultViewModel : ReactiveObject
{
public MetadataScanResultViewModel(
MetadataScanEvent.Matched matched,
Func<MetadataScanResultViewModel, VideoMetadataMatch, Task> apply)
{
ArgumentNullException.ThrowIfNull(matched);
VideoId = matched.VideoId;
VideoTitle = matched.VideoTitle;
foreach (var match in matched.Matches)
{
Matches.Add(new MetadataMatchViewModel(match, candidate => apply(this, candidate)));
}
// Several candidates mean the sources disagree, which is the one case the user has to
// resolve rather than skim — worth saying so on the row itself.
AppliedFrom = matched.Applied ? matched.Matches[0].SourceName : null;
}
public Guid VideoId { get; }
public string VideoTitle { get; }
public ObservableCollection<MetadataMatchViewModel> Matches { get; } = [];
public bool IsAmbiguous => Matches.Count > 1;
/// <summary>Which source was written onto the video, or <c>null</c> while nothing has been.</summary>
[Reactive]
public partial string? AppliedFrom { get; set; }
public void MarkApplied(string sourceName) => AppliedFrom = sourceName;
}
@@ -0,0 +1,192 @@
using System.Collections.ObjectModel;
using System.Reactive.Linq;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PLib.Application.Library;
using PLib.Application.Metadata;
using ReactiveUI;
using ReactiveUI.SourceGenerators;
using RxVoid = ReactiveUI.Primitives.RxVoid;
namespace PLib.Desktop.ViewModels;
/// <summary>
/// The library-wide metadata run: one page, one button, and a list of what came back.
/// </summary>
/// <remarks>
/// A page of its own rather than a background job. It talks to somebody else's servers on the
/// user's behalf, hundreds of times in a row — that belongs somewhere the user is looking at
/// it and can stop it, not behind a progress pill in the status bar.
/// </remarks>
public sealed partial class MetadataScanViewModel : ViewModelBase
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger _logger;
/// <summary>Called after the run so the grid can pick up renamed videos and new labels.</summary>
private readonly Func<Task> _refreshLibrary;
private CancellationTokenSource? _running;
public MetadataScanViewModel(
IServiceScopeFactory scopeFactory,
Func<Task> refreshLibrary,
ILogger logger)
{
_scopeFactory = scopeFactory;
_refreshLibrary = refreshLibrary;
_logger = logger;
StartCommand = ReactiveCommand.CreateFromTask(
RunAsync,
this.WhenAnyValue(x => x.IsRunning).Select(running => !running));
StopCommand = ReactiveCommand.Create(
() => _running?.Cancel(),
this.WhenAnyValue(x => x.IsRunning));
ObserveCommandFailures();
}
/// <summary>Videos the run found something for, newest first.</summary>
public ObservableCollection<MetadataScanResultViewModel> Results { get; } = [];
/// <summary>Sources that dropped out, one line each.</summary>
public ObservableCollection<string> Problems { get; } = [];
public ReactiveCommand<RxVoid, RxVoid> StartCommand { get; }
public ReactiveCommand<RxVoid, RxVoid> StopCommand { get; }
[Reactive]
public partial bool IsRunning { get; set; }
[Reactive]
public partial double Progress { get; set; }
[Reactive]
public partial string StatusText { get; set; } = "Опрос источников по отпечаткам всей библиотеки.";
/// <summary>Skip videos that already have a description; on by default.</summary>
[Reactive]
public partial bool OnlyWithoutDescription { get; set; } = true;
/// <summary>
/// Write a result without asking when exactly one candidate came back. Off by default —
/// a fingerprint match is a proposal, and this page exists so the user can look at them.
/// </summary>
[Reactive]
public partial bool ApplyUnambiguous { get; set; }
private async Task RunAsync()
{
using var cancellation = new CancellationTokenSource();
_running = cancellation;
IsRunning = true;
Progress = 0;
Results.Clear();
Problems.Clear();
StatusText = "Идёт опрос источников…";
var request = new MetadataScanRequest(OnlyWithoutDescription, ApplyUnambiguous);
try
{
// Off the UI thread entirely; every event is marshalled back explicitly, the same
// way the library scan does it.
await Task.Run(
async () =>
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
await foreach (var scanEvent in library.ScanMetadataAsync(request, cancellation.Token))
{
await Dispatcher.UIThread.InvokeAsync(() => Handle(scanEvent));
}
},
cancellation.Token);
}
catch (OperationCanceledException)
{
StatusText = $"Остановлено. {Described(Results.Count)}";
}
finally
{
IsRunning = false;
_running = null;
// Titles and labels may have changed under the grid whether the run finished or
// was stopped, so the refresh belongs here rather than on the success path.
await _refreshLibrary();
}
}
private void Handle(MetadataScanEvent scanEvent)
{
switch (scanEvent)
{
case MetadataScanEvent.Progress progress:
Progress = progress.Total == 0 ? 100 : progress.Processed * 100.0 / progress.Total;
StatusText = $"Проверено {progress.Processed} из {progress.Total}";
break;
case MetadataScanEvent.Matched matched:
Results.Insert(0, new MetadataScanResultViewModel(matched, ApplyAsync));
break;
case MetadataScanEvent.SourceAbandoned abandoned:
Problems.Add($"{abandoned.SourceName}: {abandoned.Reason}");
break;
case MetadataScanEvent.Completed completed:
Progress = 100;
StatusText = completed.Processed == 0
? "Нечего проверять — у видео ещё нет отпечатков, либо все уже описаны"
: $"Проверено {completed.Processed}, совпадения у {completed.WithMatches}, применено {completed.Applied}";
break;
default:
break;
}
}
private static string Described(int count) =>
count == 0 ? "Совпадений пока не было." : $"Совпадений найдено: {count}.";
/// <summary>
/// Handed to every result row. Failures are swallowed into the status line on purpose:
/// the commands live on rows that come and go with each run, so there is no stable place
/// to observe their exceptions — and an unobserved one takes the process down.
/// </summary>
private async Task ApplyAsync(MetadataScanResultViewModel result, VideoMetadataMatch match)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
await library.ApplyMetadataAsync(result.VideoId, match);
result.MarkApplied(match.SourceName);
await _refreshLibrary();
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not apply metadata to {Video}", result.VideoTitle);
StatusText = "Не удалось применить — подробности в журнале";
}
}
private void ObserveCommandFailures() =>
Observable
.Merge(StartCommand.ThrownExceptions, StopCommand.ThrownExceptions)
.Subscribe(ex =>
{
_logger.LogError(ex, "The metadata run failed");
StatusText = "Не удалось выполнить — подробности в журнале";
})
.AddTo(Subscriptions);
}
@@ -87,6 +87,17 @@ public sealed partial class VideoCardViewModel : ReactiveObject
[Reactive]
public partial string? ResumeText { get; set; }
/// <summary>
/// Identifiers of every label on this video, so the grid can be narrowed to one without a
/// query per card. Reactive because the filter has to re-evaluate when they change.
/// </summary>
[Reactive]
public partial IReadOnlyCollection<Guid> LabelIds { get; set; } = [];
/// <summary>The same labels as text, so a search term can reach them.</summary>
[Reactive]
public partial string? LabelText { get; set; }
public DateTimeOffset AddedAt { get; private set; }
/// <summary>Where playback stopped last time, or <c>null</c> if there is nothing to resume.</summary>
@@ -133,7 +144,29 @@ public sealed partial class VideoCardViewModel : ReactiveObject
: null;
}
/// <summary>
/// Copies the labels into the card.
/// </summary>
/// <remarks>
/// Apart from <see cref="Apply"/> on purpose: most of the places that hand this card a
/// <see cref="VideoItem"/> — the scan above all — load it without its labels, and an empty
/// collection there is "not loaded", not "none". Calling this is how a caller says it
/// actually knows.
/// </remarks>
public void ApplyLabels(IEnumerable<LibraryLabel> labels)
{
var ordered = labels
.OrderBy(label => label.Name, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
LabelIds = [.. ordered.Select(label => label.Id)];
LabelText = ordered.Length == 0 ? null : string.Join(" ", ordered.Select(label => label.Name));
}
public bool Matches(string term) =>
Title.Contains(term, StringComparison.CurrentCultureIgnoreCase) ||
FullPath.Contains(term, StringComparison.CurrentCultureIgnoreCase);
FullPath.Contains(term, StringComparison.CurrentCultureIgnoreCase) ||
LabelText?.Contains(term, StringComparison.CurrentCultureIgnoreCase) == true;
public bool HasLabel(Guid labelId) => LabelIds.Contains(labelId);
}
@@ -267,6 +267,11 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
Title = video.Title;
Description = video.Description;
// The grid filters by label, so the card behind this page has to hear about every
// label added or removed here — otherwise a tag would not narrow the grid until the
// library was reloaded.
Card.ApplyLabels(video.Labels);
foreach (var label in video.Labels.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
{
Target(label.Kind).Add(new LabelViewModel(label, entry => _ = DetachAsync(entry)));
+115 -12
View File
@@ -24,6 +24,10 @@
<views:VideoPlayerView />
</DataTemplate>
<DataTemplate x:Key="MetadataScanTemplate" DataType="vm:MetadataScanViewModel">
<views:MetadataScanView />
</DataTemplate>
<!-- ======================= Video card ======================= -->
<DataTemplate x:Key="VideoCardTemplate" DataType="vm:VideoCardViewModel">
<Button Classes="card" Command="{Binding PlayCommand}" ToolTip.Tip="{Binding FullPath}">
@@ -152,6 +156,24 @@
<TextBlock Classes="sectionTitle" Text="Видеотека" />
<TextBlock Classes="cardMeta" Text="{Binding Videos.Count, StringFormat='{}{0} видео'}" />
</StackPanel>
<!-- Navigation. Text tabs rather than icons: "Актёры" and "Студии" have no icon a
reader would guess, and a wrong guess costs a click to find out. -->
<ItemsControl ItemsSource="{Binding Sections}" VerticalAlignment="Center" Margin="10,0,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="2" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SectionTabViewModel">
<Button Classes="sectionTab"
Classes.active="{Binding IsSelected}"
Command="{Binding SelectCommand}"
Content="{Binding Title}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<TextBox Grid.Column="1"
@@ -213,24 +235,105 @@
<!-- Библиотека -->
<Panel IsVisible="{Binding !IsPlayerOpen}">
<ScrollViewer Padding="20,18" HorizontalScrollBarVisibility="Disabled">
<ItemsRepeater ItemsSource="{Binding Videos}" ItemTemplate="{StaticResource VideoCardTemplate}">
<ItemsRepeater.Layout>
<UniformGridLayout ItemsStretch="Fill"
MinItemWidth="230"
MinItemHeight="212"
MinColumnSpacing="16"
MinRowSpacing="16" />
</ItemsRepeater.Layout>
</ItemsRepeater>
</ScrollViewer>
<Grid RowDefinitions="Auto,*" IsVisible="{Binding IsVideosSection}">
<!-- The label the grid is narrowed to. Present only while one is active, so the row
costs nothing when the grid shows everything. -->
<Border Grid.Row="0"
Classes="filterBar"
IsVisible="{Binding ActiveLabel, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Classes="cardMeta" VerticalAlignment="Center" Text="Отбор:" />
<TextBlock Classes="panelTitle"
VerticalAlignment="Center"
Text="{Binding ActiveLabel.Name}" />
<Button Classes="transport"
Padding="4"
Command="{Binding ClearLabelFilterCommand}"
ToolTip.Tip="Показать все видео">
<icons:MaterialIcon Kind="Close" Width="13" Height="13" />
</Button>
</StackPanel>
</Border>
<ScrollViewer Grid.Row="1" Padding="20,18" HorizontalScrollBarVisibility="Disabled">
<ItemsRepeater ItemsSource="{Binding Videos}" ItemTemplate="{StaticResource VideoCardTemplate}">
<ItemsRepeater.Layout>
<UniformGridLayout ItemsStretch="Fill"
MinItemWidth="230"
MinItemHeight="212"
MinColumnSpacing="16"
MinRowSpacing="16" />
</ItemsRepeater.Layout>
</ItemsRepeater>
</ScrollViewer>
</Grid>
<!-- ================= Теги, актёры, студии, коллекции ================= -->
<Grid RowDefinitions="Auto,*" IsVisible="{Binding IsEntitySection}">
<Border Grid.Row="0" Classes="filterBar">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12">
<TextBox Grid.Column="0"
MaxWidth="360"
HorizontalAlignment="Left"
PlaceholderText="Поиск…"
Text="{Binding EntitySearchText}" />
<ComboBox Grid.Column="1"
MinWidth="180"
ItemsSource="{Binding EntitySortOptions}"
SelectedItem="{Binding SelectedEntitySort}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:EntitySortOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</Grid>
</Border>
<ScrollViewer Grid.Row="1" Padding="20,18" HorizontalScrollBarVisibility="Disabled">
<ItemsRepeater ItemsSource="{Binding Entities}">
<ItemsRepeater.Layout>
<UniformGridLayout ItemsStretch="Fill"
MinItemWidth="220"
MinItemHeight="56"
MinColumnSpacing="12"
MinRowSpacing="12" />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="vm:LabelSummaryViewModel">
<Button Classes="entity" Command="{Binding OpenCommand}">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
<TextBlock Grid.Column="0"
VerticalAlignment="Center"
Text="{Binding Name}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Name}"
FontSize="13"
Foreground="{DynamicResource TextPrimaryBrush}" />
<Border Grid.Column="1" Classes="badge" VerticalAlignment="Center">
<TextBlock Text="{Binding CountText}" />
</Border>
</Grid>
</Button>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</ScrollViewer>
</Grid>
<!-- ======================= Метаданные ======================= -->
<ContentControl Content="{Binding MetadataScan}"
ContentTemplate="{StaticResource MetadataScanTemplate}"
IsVisible="{Binding IsMetadataSection}" />
<!-- Empty state -->
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="14"
MaxWidth="420"
IsVisible="{Binding IsEmpty}">
IsVisible="{Binding IsLibraryPromptVisible}">
<icons:MaterialIcon Kind="VideoBoxOff"
Width="52"
Height="52"
@@ -0,0 +1,137 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:icons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:vm="clr-namespace:PLib.Desktop.ViewModels"
x:Class="PLib.Desktop.Views.MetadataScanView"
x:DataType="vm:MetadataScanViewModel">
<Grid RowDefinitions="Auto,*">
<!-- ======================= Controls ======================= -->
<Border Grid.Row="0" Classes="filterBar">
<StackPanel Spacing="10">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="10">
<Button Grid.Column="0"
Classes="Primary"
Command="{Binding StartCommand}">
<StackPanel Orientation="Horizontal" Spacing="7">
<icons:MaterialIcon Kind="DatabaseSearchOutline" Width="16" Height="16" />
<TextBlock Text="Начать поиск" />
</StackPanel>
</Button>
<Button Grid.Column="1"
Content="Остановить"
Command="{Binding StopCommand}"
IsVisible="{Binding IsRunning}" />
<ProgressBar Grid.Column="2"
VerticalAlignment="Center"
Minimum="0"
Maximum="100"
Value="{Binding Progress}"
IsVisible="{Binding IsRunning}" />
<TextBlock Grid.Column="3"
Classes="subtle"
VerticalAlignment="Center"
Text="{Binding StatusText}" />
</Grid>
<StackPanel Orientation="Horizontal" Spacing="18">
<CheckBox Content="Только без описания" IsChecked="{Binding OnlyWithoutDescription}" />
<CheckBox Content="Применять однозначные сразу" IsChecked="{Binding ApplyUnambiguous}" />
</StackPanel>
<!-- Sources that dropped out. Reported once each: a rejected key fails on every
video, and a line per video would bury the results. -->
<ItemsControl ItemsSource="{Binding Problems}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Classes="cardMeta"
TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}"
Text="{Binding}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- ======================= Results ======================= -->
<ScrollViewer Grid.Row="1" Padding="20,18">
<ItemsControl ItemsSource="{Binding Results}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MetadataScanResultViewModel">
<Border Background="{DynamicResource CardBackgroundBrush}"
BorderBrush="{DynamicResource CardBorderBrush}"
BorderThickness="1"
CornerRadius="10"
Padding="12,10"
Margin="0,0,0,10">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
<TextBlock Grid.Column="0"
Text="{Binding VideoTitle}"
FontSize="13"
FontWeight="SemiBold"
TextWrapping="Wrap"
Foreground="{DynamicResource TextPrimaryBrush}" />
<Border Grid.Column="1"
Classes="badge"
VerticalAlignment="Center"
IsVisible="{Binding AppliedFrom, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding AppliedFrom, StringFormat='применено · {0}'}" />
</Border>
</Grid>
<TextBlock Classes="cardMeta"
Text="Источники расходятся — выберите сами"
IsVisible="{Binding IsAmbiguous}" />
<ItemsControl ItemsSource="{Binding Matches}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:MetadataMatchViewModel">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10" Margin="0,0,0,6">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Title}"
FontSize="12.5"
TextWrapping="Wrap"
Foreground="{DynamicResource TextPrimaryBrush}" />
<TextBlock Classes="cardMeta" Text="{Binding SourceName}" />
<TextBlock Classes="cardMeta"
TextWrapping="Wrap"
Text="{Binding Studios, StringFormat='Студия: {0}'}"
IsVisible="{Binding Studios, Converter={x:Static ObjectConverters.IsNotNull}}" />
<TextBlock Classes="cardMeta"
TextWrapping="Wrap"
Text="{Binding Performers, StringFormat='Актёры: {0}'}"
IsVisible="{Binding Performers, Converter={x:Static ObjectConverters.IsNotNull}}" />
<TextBlock Classes="cardMeta"
TextWrapping="Wrap"
Text="{Binding Tags, StringFormat='Теги: {0}'}"
IsVisible="{Binding Tags, Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel>
<Button Grid.Column="1"
VerticalAlignment="Center"
Command="{Binding ApplyCommand}"
Content="Применить" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</UserControl>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace PLib.Desktop.Views;
public sealed partial class MetadataScanView : UserControl
{
public MetadataScanView() => InitializeComponent();
}
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using PLib.Application.Abstractions;
using PLib.Application.Library;
using PLib.Domain.Videos;
namespace PLib.Infrastructure.Persistence;
@@ -10,6 +11,12 @@ public sealed class EfLabelRepository(LibraryDbContext dbContext) : ILabelReposi
public async Task<IReadOnlyList<LibraryLabel>> GetAllAsync(CancellationToken cancellationToken = default) =>
await dbContext.Labels.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<LabelSummary>> GetSummariesAsync(
CancellationToken cancellationToken = default) =>
await dbContext.Labels
.Select(label => new LabelSummary(label.Id, label.Name, label.Kind, label.Videos.Count))
.ToListAsync(cancellationToken);
public Task<LibraryLabel?> FindAsync(
LabelKind kind,
string name,
@@ -12,6 +12,16 @@ public sealed class EfVideoRepository(LibraryDbContext dbContext) : IVideoReposi
.OrderByDescending(x => x.AddedAt)
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<VideoItem>> GetAllWithLabelsAsync(
CancellationToken cancellationToken = default) =>
await dbContext.Videos
.Include(x => x.Labels)
// Split: with the join in one query every video row would come back once per
// label, and the rows are wide enough for that to cost real time on a big library.
.AsSplitQuery()
.OrderByDescending(x => x.AddedAt)
.ToListAsync(cancellationToken);
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
dbContext.Videos.FirstOrDefaultAsync(x => x.FullPath == fullPath, cancellationToken);
@@ -1,4 +1,5 @@
using PLib.Application.Abstractions;
using PLib.Application.Library;
using PLib.Domain.Videos;
namespace PLib.Tests.Library;
@@ -11,6 +12,10 @@ internal sealed class InMemoryLabelRepository : ILabelRepository
public Task<IReadOnlyList<LibraryLabel>> GetAllAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<LibraryLabel>>([.. _labels]);
public Task<IReadOnlyList<LabelSummary>> GetSummariesAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<LabelSummary>>(
[.. _labels.Select(label => new LabelSummary(label.Id, label.Name, label.Kind, label.Videos.Count))]);
public Task<LibraryLabel?> FindAsync(
LabelKind kind,
string name,
@@ -27,6 +27,10 @@ internal sealed class InMemoryVideoRepository : IVideoRepository
public Task<IReadOnlyList<VideoItem>> GetAllAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<VideoItem>>([.. _items.Values]);
// Labels are held on the entity itself here, so this is the same list.
public Task<IReadOnlyList<VideoItem>> GetAllWithLabelsAsync(CancellationToken cancellationToken = default) =>
GetAllAsync(cancellationToken);
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
Task.FromResult(_items.GetValueOrDefault(fullPath));
@@ -18,6 +18,9 @@ internal sealed class MetadataMonitor(MetadataOptions value) : IOptionsMonitor<M
public static MetadataMonitor With(params MetadataSourceOptions[] sources) =>
new(new MetadataOptions { Sources = [.. sources] });
public static MetadataMonitor With(IReadOnlyList<MetadataSourceOptions> sources, int delayMilliseconds) =>
new(new MetadataOptions { Sources = [.. sources], RequestDelayMilliseconds = delayMilliseconds });
public MetadataOptions CurrentValue { get; } = value;
public MetadataOptions Get(string? name) => CurrentValue;
@@ -0,0 +1,161 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using PLib.Application.Abstractions;
using PLib.Application.Library;
using PLib.Application.Metadata;
using PLib.Domain.Videos;
using Shouldly;
namespace PLib.Tests.Library;
/// <summary>
/// The library-wide run. Its job is not to find metadata — that is the provider's — but to
/// decide what to ask about, what to write without asking, and when to stop asking a source.
/// </summary>
public sealed class MetadataScanTests
{
private readonly InMemoryVideoRepository _videos = new();
private readonly InMemoryLabelRepository _labels = new();
private readonly IMetadataProvider _provider = Substitute.For<IMetadataProvider>();
private readonly MetadataSourceOptions _source =
new() { Name = "StashDB", Endpoint = "https://stashdb.org/graphql" };
[Fact]
public async Task Only_fingerprinted_videos_are_asked_about()
{
var hashed = Video("с отпечатком", hash: 1);
_videos.Seed(hashed, Video("без отпечатка", hash: null));
Answer(_source, []);
var events = await CollectAsync();
// Without a fingerprint there is no question to ask, so the video is not counted as
// processed either — a total that included it would never reach itself.
events.OfType<MetadataScanEvent.Completed>().Single().Processed.ShouldBe(1);
await _provider.Received(1).FindByPerceptualHashAsync(_source, 1, Arg.Any<CancellationToken>());
}
[Fact]
public async Task Videos_that_already_have_a_description_are_skipped_by_default()
{
var described = Video("описанное", hash: 1);
described.Describe("уже есть");
_videos.Seed(described, Video("голое", hash: 2));
Answer(_source, []);
(await CollectAsync()).OfType<MetadataScanEvent.Completed>().Single().Processed.ShouldBe(1);
// Asking again for everything is what the switch is for.
var all = await CollectAsync(new MetadataScanRequest(OnlyWithoutDescription: false));
all.OfType<MetadataScanEvent.Completed>().Single().Processed.ShouldBe(2);
}
[Fact]
public async Task A_single_candidate_is_written_only_when_the_run_was_told_it_may()
{
var video = Video("видео", hash: 1);
_videos.Seed(video);
Answer(_source, [Match("Название")]);
await CollectAsync();
video.Description.ShouldBeNull();
var applied = await CollectAsync(new MetadataScanRequest(ApplyUnambiguous: true));
video.Title.ShouldBe("Название");
applied.OfType<MetadataScanEvent.Completed>().Single().Applied.ShouldBe(1);
applied.OfType<MetadataScanEvent.Matched>().Single().Applied.ShouldBeTrue();
}
[Fact]
public async Task Disagreeing_sources_are_never_written_without_asking()
{
var video = Video("видео", hash: 1);
_videos.Seed(video);
var second = new MetadataSourceOptions { Name = "Другой", Endpoint = "https://other/graphql" };
Answer(_source, [Match("Одно")]);
Answer(second, [Match("Другое")]);
var events = await CollectAsync(new MetadataScanRequest(ApplyUnambiguous: true), _source, second);
// Two candidates is precisely the case a human has to resolve.
video.Title.ShouldBe("видео");
events.OfType<MetadataScanEvent.Matched>().Single().Applied.ShouldBeFalse();
events.OfType<MetadataScanEvent.Completed>().Single().Applied.ShouldBe(0);
}
[Fact]
public async Task A_failing_source_is_reported_once_and_then_left_out_of_the_run()
{
_videos.Seed(Video("первое", hash: 1), Video("второе", hash: 2), Video("третье", hash: 3));
_provider.FindByPerceptualHashAsync(_source, Arg.Any<ulong>(), Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<VideoMetadataMatch>>(_ => throw new HttpRequestException("401"));
var events = await CollectAsync();
// A rejected key fails on every video; three identical lines would bury the results,
// and three hundred would be the whole page.
events.OfType<MetadataScanEvent.SourceAbandoned>().ShouldHaveSingleItem();
await _provider.Received(1).FindByPerceptualHashAsync(
_source,
Arg.Any<ulong>(),
Arg.Any<CancellationToken>());
// The run still finishes, having gone through every video and found nothing.
events.OfType<MetadataScanEvent.Completed>().Single().Processed.ShouldBe(3);
}
private void Answer(MetadataSourceOptions source, IReadOnlyList<VideoMetadataMatch> matches) =>
_provider.FindByPerceptualHashAsync(source, Arg.Any<ulong>(), Arg.Any<CancellationToken>())
.Returns(matches);
private static VideoMetadataMatch Match(string title) =>
new("StashDB", "id", title, "описание", [], [], []);
private static VideoItem Video(string title, ulong? hash)
{
var item = new VideoItem($@"C:\videos\{title}.mp4", title, 1_000, DateTimeOffset.UnixEpoch);
item.ApplyTechnicalInfo(new VideoTechnicalInfo(TimeSpan.FromMinutes(1), 1280, 720, "h264"));
item.ApplyPerceptualHash(hash);
return item;
}
private async Task<List<MetadataScanEvent>> CollectAsync(
MetadataScanRequest? request = null,
params MetadataSourceOptions[] sources)
{
var service = new LibraryService(
_videos,
_labels,
Substitute.For<IVideoFileScanner>(),
Substitute.For<IMediaProbe>(),
Substitute.For<IThumbnailGenerator>(),
Substitute.For<IAnimatedPreviewGenerator>(),
Substitute.For<IVideoPerceptualHasher>(),
_provider,
Options.Create(new LibraryOptions()),
// No pause between requests: the delay exists to be kind to somebody else's
// server, and there isn't one here.
MetadataMonitor.With(sources.Length == 0 ? [_source] : sources, delayMilliseconds: 0),
NullLogger<LibraryService>.Instance);
var events = new List<MetadataScanEvent>();
await foreach (var scanEvent in service.ScanMetadataAsync(
request ?? new MetadataScanRequest(),
TestContext.Current.CancellationToken))
{
events.Add(scanEvent);
}
return events;
}
}
@@ -0,0 +1,84 @@
using NSubstitute;
using PLib.Desktop.Services;
using PLib.Desktop.ViewModels;
using PLib.Domain.Videos;
using Shouldly;
namespace PLib.Tests.ViewModels;
/// <summary>
/// The card carries its labels so the grid can be narrowed to one without a query per card.
/// </summary>
public sealed class VideoCardLabelTests
{
[Fact]
public void Labels_are_only_taken_when_a_caller_says_it_actually_loaded_them()
{
var item = Video();
item.AddLabel(new LibraryLabel("Драма", LabelKind.Tag));
var card = Card(item);
// Apply alone is the scan's path, and the scan loads videos without their labels —
// an empty collection there means "not loaded", not "none".
card.Apply(item);
card.LabelIds.ShouldBeEmpty();
card.ApplyLabels(item.Labels);
card.LabelIds.Count.ShouldBe(1);
}
[Fact]
public void A_card_is_kept_by_the_filter_only_for_labels_it_carries()
{
var mine = new LibraryLabel("Моё", LabelKind.Tag);
var other = new LibraryLabel("Чужое", LabelKind.Studio);
var item = Video();
item.AddLabel(mine);
var card = Card(item);
card.ApplyLabels(item.Labels);
card.HasLabel(mine.Id).ShouldBeTrue();
card.HasLabel(other.Id).ShouldBeFalse();
}
[Fact]
public void Searching_reaches_the_label_names_as_well_as_the_title_and_the_path()
{
var item = Video();
item.AddLabel(new LibraryLabel("Кристофер Нолан", LabelKind.Performer));
var card = Card(item);
card.ApplyLabels(item.Labels);
// Typing a performer's name is a search, not a trip to another tab.
card.Matches("нолан").ShouldBeTrue();
card.Matches("clip").ShouldBeTrue();
card.Matches("чего-то ещё").ShouldBeFalse();
}
[Fact]
public void Reapplying_labels_replaces_them_rather_than_accumulating()
{
var item = Video();
item.AddLabel(new LibraryLabel("Первый", LabelKind.Tag));
var card = Card(item);
card.ApplyLabels(item.Labels);
item.RemoveLabel(item.Labels.Single().Id);
card.ApplyLabels(item.Labels);
// A label removed on the media page has to stop narrowing the grid straight away.
card.LabelIds.ShouldBeEmpty();
card.LabelText.ShouldBeNull();
}
private static VideoItem Video() =>
new(@"C:\videos\clip.mp4", "clip", 1_000, DateTimeOffset.UnixEpoch);
private static VideoCardViewModel Card(VideoItem item) =>
new(item, Substitute.For<ISystemShell>(), _ => { });
}