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
@@ -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();
}