Implement metadata management features in PLib video library manager. Introduce functionality to query and apply metadata from external sources based on video fingerprints. Update ILibraryService and LibraryService to support metadata lookup and application, enhancing video item descriptions and labels. Revise UI components in VideoPlayerView and SettingsView to facilitate user interaction with metadata sources. Update README.md to document new metadata features and usage instructions.
This commit is contained in:
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Application.Metadata;
|
||||
using PLib.Desktop.Settings;
|
||||
using PLib.Infrastructure.Storage;
|
||||
|
||||
@@ -12,7 +13,8 @@ public sealed class JsonAppSettingsStore(
|
||||
IAppPaths paths,
|
||||
IOptionsMonitor<LibraryOptions> library,
|
||||
IOptionsMonitor<AppearanceOptions> appearance,
|
||||
IOptionsMonitor<PlaybackOptions> playback) : IAppSettingsStore
|
||||
IOptionsMonitor<PlaybackOptions> playback,
|
||||
IOptionsMonitor<MetadataOptions> metadata) : IAppSettingsStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
||||
|
||||
@@ -20,8 +22,11 @@ public sealed class JsonAppSettingsStore(
|
||||
|
||||
private string SettingsFile => Path.Combine(paths.DataDirectory, "settings.json");
|
||||
|
||||
public AppSettings Current =>
|
||||
AppSettings.From(library.CurrentValue, appearance.CurrentValue, playback.CurrentValue);
|
||||
public AppSettings Current => AppSettings.From(
|
||||
library.CurrentValue,
|
||||
appearance.CurrentValue,
|
||||
playback.CurrentValue,
|
||||
metadata.CurrentValue);
|
||||
|
||||
public async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -46,6 +51,19 @@ public sealed class JsonAppSettingsStore(
|
||||
playbackSection["Volume"] = Math.Round(settings.Volume, 3);
|
||||
playbackSection["IsMuted"] = settings.IsMuted;
|
||||
|
||||
// Written whole rather than merged per entry: the list has an order the user set
|
||||
// and entries with no key of their own, so there is nothing to merge against.
|
||||
Section(root, MetadataOptions.SectionName)["Sources"] = new JsonArray(
|
||||
[
|
||||
.. settings.MetadataSources.Select(source => (JsonNode)new JsonObject
|
||||
{
|
||||
["Name"] = source.Name,
|
||||
["Endpoint"] = source.Endpoint,
|
||||
["ApiKey"] = source.ApiKey,
|
||||
["IsEnabled"] = source.IsEnabled,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Write through a temp file so an interrupted save cannot corrupt the settings.
|
||||
var staging = SettingsFile + ".tmp";
|
||||
await File.WriteAllTextAsync(staging, root.ToJsonString(WriteOptions), cancellationToken);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using PLib.Application.Library;
|
||||
using PLib.Application.Metadata;
|
||||
|
||||
namespace PLib.Desktop.Settings;
|
||||
|
||||
@@ -27,10 +28,14 @@ public sealed record AppSettings
|
||||
|
||||
public required bool IsMuted { get; init; }
|
||||
|
||||
/// <summary>GraphQL endpoints that may be asked about a video, in the order shown.</summary>
|
||||
public required IReadOnlyList<MetadataSourceOptions> MetadataSources { get; init; }
|
||||
|
||||
public static AppSettings From(
|
||||
LibraryOptions library,
|
||||
AppearanceOptions appearance,
|
||||
PlaybackOptions playback) => new()
|
||||
PlaybackOptions playback,
|
||||
MetadataOptions metadata) => new()
|
||||
{
|
||||
Folders = [.. library.Folders],
|
||||
ThumbnailWidth = library.ThumbnailWidth,
|
||||
@@ -40,6 +45,7 @@ public sealed record AppSettings
|
||||
Theme = appearance.Theme,
|
||||
Volume = playback.Volume,
|
||||
IsMuted = playback.IsMuted,
|
||||
MetadataSources = [.. metadata.Sources],
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using PLib.Application.Metadata;
|
||||
using ReactiveUI;
|
||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>One candidate returned by a metadata source, offered for the user to accept.</summary>
|
||||
/// <remarks>
|
||||
/// A match is never applied on arrival. Fingerprints collide across re-encodes and trailers,
|
||||
/// and a source confidently overwriting a title is far harder to undo than a button is to press.
|
||||
/// </remarks>
|
||||
public sealed class MetadataMatchViewModel
|
||||
{
|
||||
public MetadataMatchViewModel(VideoMetadataMatch match, Func<VideoMetadataMatch, Task> apply)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(match);
|
||||
|
||||
Match = match;
|
||||
SourceName = match.SourceName;
|
||||
Title = match.Title;
|
||||
Description = match.Description;
|
||||
|
||||
Studios = Join(match.Studios);
|
||||
Performers = Join(match.Performers);
|
||||
Tags = Join(match.Tags);
|
||||
|
||||
ApplyCommand = ReactiveCommand.CreateFromTask(() => apply(match));
|
||||
}
|
||||
|
||||
public VideoMetadataMatch Match { get; }
|
||||
|
||||
public string SourceName { get; }
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
/// <summary>Comma-separated for display; the lists themselves stay on <see cref="Match"/>.</summary>
|
||||
public string? Studios { get; }
|
||||
|
||||
public string? Performers { get; }
|
||||
|
||||
public string? Tags { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ApplyCommand { get; }
|
||||
|
||||
private static string? Join(IReadOnlyList<string> values) =>
|
||||
values.Count == 0 ? null : string.Join(", ", values);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using PLib.Application.Metadata;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||
|
||||
namespace PLib.Desktop.ViewModels;
|
||||
|
||||
/// <summary>One metadata source being edited in the settings panel.</summary>
|
||||
/// <remarks>
|
||||
/// Unlike the folder rows, this one is editable in place: a source is three fields, and a
|
||||
/// separate dialog to fill them in would be more ceremony than the thing deserves.
|
||||
/// </remarks>
|
||||
public sealed partial class MetadataSourceEntryViewModel : ReactiveObject
|
||||
{
|
||||
public MetadataSourceEntryViewModel(
|
||||
MetadataSourceOptions source,
|
||||
Action<MetadataSourceEntryViewModel> remove)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
Name = source.Name;
|
||||
Endpoint = source.Endpoint;
|
||||
ApiKey = source.ApiKey;
|
||||
IsEnabled = source.IsEnabled;
|
||||
|
||||
RemoveCommand = ReactiveCommand.Create(() => remove(this));
|
||||
}
|
||||
|
||||
[Reactive]
|
||||
public partial string Name { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial string Endpoint { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public partial string ApiKey { get; set; }
|
||||
|
||||
/// <summary>Lets a source be silenced without losing its endpoint and key.</summary>
|
||||
[Reactive]
|
||||
public partial bool IsEnabled { get; set; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> RemoveCommand { get; }
|
||||
|
||||
public MetadataSourceOptions ToOptions() => new()
|
||||
{
|
||||
Name = Name?.Trim() ?? string.Empty,
|
||||
Endpoint = Endpoint?.Trim() ?? string.Empty,
|
||||
ApiKey = ApiKey?.Trim() ?? string.Empty,
|
||||
IsEnabled = IsEnabled,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Reactive.Subjects;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Application.Metadata;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Desktop.Settings;
|
||||
using ReactiveUI;
|
||||
@@ -53,6 +54,7 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
||||
_original = settingsStore.Current;
|
||||
|
||||
Folders = [.. _original.Folders.Select(CreateEntry)];
|
||||
MetadataSources = [.. _original.MetadataSources.Select(CreateEntry)];
|
||||
ThumbnailWidth = _original.ThumbnailWidth;
|
||||
ThumbnailPositionPercent = ToPercent(_original.ThumbnailPositionRatio);
|
||||
MaxIndexingConcurrency = _original.MaxIndexingConcurrency;
|
||||
@@ -60,6 +62,7 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
||||
SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
|
||||
|
||||
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
|
||||
AddMetadataSourceCommand = ReactiveCommand.Create(AddMetadataSource);
|
||||
RefreshUsageCommand = ReactiveCommand.CreateFromTask(RefreshUsageAsync);
|
||||
|
||||
// One gate for every clearing button: they all talk to the same database and the same
|
||||
@@ -115,6 +118,10 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
||||
|
||||
public bool HasFolders => Folders.Count > 0;
|
||||
|
||||
public ObservableCollection<MetadataSourceEntryViewModel> MetadataSources { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> AddMetadataSourceCommand { get; }
|
||||
|
||||
public IReadOnlyList<ThemeOption> ThemeOptions { get; } = ThemeOption.All;
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> AddFolderCommand { get; }
|
||||
@@ -178,6 +185,15 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
||||
: (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte),
|
||||
|
||||
Theme = SelectedTheme.Mode,
|
||||
|
||||
// Rows with nothing in them are what a half-finished edit looks like, and saving them
|
||||
// would put empty entries in the file for the next opening to show again.
|
||||
MetadataSources =
|
||||
[
|
||||
.. MetadataSources
|
||||
.Select(entry => entry.ToOptions())
|
||||
.Where(source => !string.IsNullOrWhiteSpace(source.Endpoint))
|
||||
],
|
||||
};
|
||||
|
||||
private static double ToPercent(double ratio) => Math.Round(ratio * 100);
|
||||
@@ -201,6 +217,12 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
||||
private FolderEntryViewModel CreateEntry(string path) =>
|
||||
new(path, entry => Folders.Remove(entry));
|
||||
|
||||
private MetadataSourceEntryViewModel CreateEntry(MetadataSourceOptions source) =>
|
||||
new(source, entry => MetadataSources.Remove(entry));
|
||||
|
||||
private void AddMetadataSource() =>
|
||||
MetadataSources.Add(CreateEntry(new MetadataSourceOptions()));
|
||||
|
||||
private async Task RefreshUsageAsync()
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
@@ -255,6 +277,7 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
||||
.Merge<Exception>(
|
||||
[
|
||||
AddFolderCommand.ThrownExceptions,
|
||||
AddMetadataSourceCommand.ThrownExceptions,
|
||||
RefreshUsageCommand.ThrownExceptions,
|
||||
ClearAllCommand.ThrownExceptions,
|
||||
SaveCommand.ThrownExceptions,
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Reactive.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Application.Metadata;
|
||||
using PLib.Desktop.Services;
|
||||
using PLib.Domain.Videos;
|
||||
using ReactiveUI;
|
||||
@@ -72,6 +73,12 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
AddCollectionCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewCollection, LabelKind.Collection));
|
||||
LoadLabelsCommand = ReactiveCommand.CreateFromTask(LoadLabelsAsync);
|
||||
|
||||
// Only ever from this button: the whole point of the feature is that nothing talks to
|
||||
// a remote service about the user's library on its own.
|
||||
LookupMetadataCommand = ReactiveCommand.CreateFromTask(
|
||||
LookupMetadataAsync,
|
||||
this.WhenAnyValue(x => x.IsLookingUp).Select(busy => !busy));
|
||||
|
||||
this.WhenAnyValue(x => x.Volume, x => x.IsMuted, (volume, muted) => (volume, muted))
|
||||
// Skip the values we just restored: they are already what is on disk.
|
||||
.Skip(1)
|
||||
@@ -88,7 +95,9 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
|
||||
public Guid VideoId { get; }
|
||||
|
||||
public string Title { get; }
|
||||
/// <summary>Reactive because applying a match renames the video under the open page.</summary>
|
||||
[Reactive]
|
||||
public partial string Title { get; set; }
|
||||
|
||||
public string FullPath { get; }
|
||||
|
||||
@@ -107,6 +116,13 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
|
||||
public ObservableCollection<LabelViewModel> Collections { get; } = [];
|
||||
|
||||
public ObservableCollection<LabelViewModel> Performers { get; } = [];
|
||||
|
||||
public ObservableCollection<LabelViewModel> Studios { get; } = [];
|
||||
|
||||
/// <summary>Candidates from the last lookup; empty until the button is pressed.</summary>
|
||||
public ObservableCollection<MetadataMatchViewModel> Matches { get; } = [];
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
|
||||
@@ -125,6 +141,19 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> LoadLabelsCommand { get; }
|
||||
|
||||
public ReactiveCommand<RxVoid, RxVoid> LookupMetadataCommand { get; }
|
||||
|
||||
[Reactive]
|
||||
public partial bool IsLookingUp { get; set; }
|
||||
|
||||
/// <summary>What the last lookup came to, including which sources failed.</summary>
|
||||
[Reactive]
|
||||
public partial string? MetadataMessage { get; set; }
|
||||
|
||||
/// <summary>Free text about the video, once a match has supplied one.</summary>
|
||||
[Reactive]
|
||||
public partial string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True while the window is given over to the video. The page hides its own header and
|
||||
/// the window hides its chrome.
|
||||
@@ -227,17 +256,105 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
|
||||
Tags.Clear();
|
||||
Collections.Clear();
|
||||
Performers.Clear();
|
||||
Studios.Clear();
|
||||
|
||||
var labels = video is null
|
||||
? []
|
||||
: video.Labels.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase).ToArray();
|
||||
if (video is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var label in labels)
|
||||
Title = video.Title;
|
||||
Description = video.Description;
|
||||
|
||||
foreach (var label in video.Labels.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
|
||||
{
|
||||
Target(label.Kind).Add(new LabelViewModel(label, entry => _ = DetachAsync(entry)));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LookupMetadataAsync()
|
||||
{
|
||||
IsLookingUp = true;
|
||||
Matches.Clear();
|
||||
|
||||
// The results land in the side panel, so opening it is part of running the lookup —
|
||||
// otherwise the button would appear to do nothing.
|
||||
AreDetailsVisible = true;
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
var result = await library.FindMetadataAsync(VideoId);
|
||||
|
||||
if (!result.HasPerceptualHash)
|
||||
{
|
||||
MetadataMessage = "Отпечаток ещё не посчитан — дождитесь окончания сканирования";
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var match in result.Matches)
|
||||
{
|
||||
Matches.Add(new MetadataMatchViewModel(match, ApplyMetadataAsync));
|
||||
}
|
||||
|
||||
MetadataMessage = Describe(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLookingUp = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Says what came back and what did not. A source that failed is reported even when the
|
||||
/// others found something, because "one match" and "one match, and StashDB was down" call
|
||||
/// for different next steps.
|
||||
/// </summary>
|
||||
private static string Describe(MetadataLookupResult result)
|
||||
{
|
||||
var found = result.Matches.Count == 0
|
||||
? "Совпадений не найдено"
|
||||
: $"Найдено совпадений: {result.Matches.Count}";
|
||||
|
||||
return result.Failures.Count == 0
|
||||
? found
|
||||
: $"{found}. Не ответили — {string.Join("; ", result.Failures)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handed to every match as its apply action. It swallows failures on purpose: the
|
||||
/// commands live on the match rows, which come and go with each lookup, so there is no
|
||||
/// stable place to observe their exceptions — and an unobserved one takes the process down.
|
||||
/// </summary>
|
||||
private async Task ApplyMetadataAsync(VideoMetadataMatch match)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||
|
||||
await library.ApplyMetadataAsync(VideoId, match);
|
||||
await LoadLabelsAsync();
|
||||
|
||||
// The grid behind the page shows the old title until the card is told otherwise.
|
||||
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
|
||||
{
|
||||
Card.Apply(refreshed);
|
||||
}
|
||||
|
||||
Matches.Clear();
|
||||
MetadataMessage = $"Применено: {match.SourceName}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Could not apply metadata from {Source}", match.SourceName);
|
||||
MetadataMessage = "Не удалось применить — подробности в журнале";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AttachAsync(string name, LabelKind kind)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
@@ -278,8 +395,13 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
private ObservableCollection<LabelViewModel> Target(LabelKind kind) =>
|
||||
kind == LabelKind.Tag ? Tags : Collections;
|
||||
private ObservableCollection<LabelViewModel> Target(LabelKind kind) => kind switch
|
||||
{
|
||||
LabelKind.Collection => Collections,
|
||||
LabelKind.Performer => Performers,
|
||||
LabelKind.Studio => Studios,
|
||||
_ => Tags,
|
||||
};
|
||||
|
||||
private void Persist(double volume, bool isMuted) => _ = PersistAsync(volume, isMuted);
|
||||
|
||||
@@ -307,7 +429,12 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
||||
RevealCommand.ThrownExceptions,
|
||||
AddTagCommand.ThrownExceptions,
|
||||
AddCollectionCommand.ThrownExceptions,
|
||||
LoadLabelsCommand.ThrownExceptions)
|
||||
.Subscribe(ex => _logger.LogError(ex, "A media page command failed"))
|
||||
LoadLabelsCommand.ThrownExceptions,
|
||||
LookupMetadataCommand.ThrownExceptions)
|
||||
.Subscribe(ex =>
|
||||
{
|
||||
_logger.LogError(ex, "A media page command failed");
|
||||
MetadataMessage = "Что-то пошло не так — подробности в журнале";
|
||||
})
|
||||
.AddTo(Subscriptions);
|
||||
}
|
||||
|
||||
@@ -135,6 +135,57 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Metadata sources -->
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="sectionTitle" FontSize="14" Text="Источники метаданных" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding MetadataSources}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MetadataSourceEntryViewModel">
|
||||
<Border Background="{DynamicResource CardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Padding="10,8"
|
||||
Margin="0,0,0,8">
|
||||
<StackPanel Spacing="6">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="8">
|
||||
<CheckBox Grid.Column="0"
|
||||
IsChecked="{Binding IsEnabled}"
|
||||
ToolTip.Tip="Опрашивать этот источник" />
|
||||
<TextBox Grid.Column="1"
|
||||
PlaceholderText="Название"
|
||||
Text="{Binding Name}" />
|
||||
<Button Grid.Column="2"
|
||||
Command="{Binding RemoveCommand}"
|
||||
Padding="6"
|
||||
ToolTip.Tip="Убрать источник">
|
||||
<icons:MaterialIcon Kind="Close" Width="14" Height="14" />
|
||||
</Button>
|
||||
</Grid>
|
||||
<TextBox PlaceholderText="https://…/graphql" Text="{Binding Endpoint}" />
|
||||
<TextBox PlaceholderText="API-ключ"
|
||||
PasswordChar="•"
|
||||
Text="{Binding ApiKey}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Button HorizontalAlignment="Left" Command="{Binding AddMetadataSourceCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<icons:MaterialIcon Kind="DatabasePlusOutline" Width="16" Height="16" />
|
||||
<TextBlock Text="Добавить источник" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<TextBlock Classes="hint"
|
||||
Text="GraphQL-эндпойнты со схемой stash-box (StashDB и подобные). Запрос идёт только по кнопке «Найти метаданные» на странице видео и только по отпечатку — сами файлы никуда не отправляются. Ключи хранятся в settings.json открытым текстом." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Scanning -->
|
||||
<Border Classes="section">
|
||||
<StackPanel Spacing="14">
|
||||
|
||||
@@ -72,6 +72,11 @@
|
||||
ToolTip.Tip="Сведения и метки">
|
||||
<icons:MaterialIcon Kind="InformationOutline" Width="17" Height="17" />
|
||||
</Button>
|
||||
<Button Classes="transport"
|
||||
Command="{Binding LookupMetadataCommand}"
|
||||
ToolTip.Tip="Найти метаданные по отпечатку (pHash)">
|
||||
<icons:MaterialIcon Kind="DatabaseSearchOutline" Width="17" Height="17" />
|
||||
</Button>
|
||||
<Button Classes="transport"
|
||||
Command="{Binding OpenExternallyCommand}"
|
||||
ToolTip.Tip="Открыть во внешнем плеере">
|
||||
@@ -141,6 +146,89 @@
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Metadata lookup: nothing here until the button in the header is pressed. -->
|
||||
<StackPanel Spacing="8"
|
||||
IsVisible="{Binding MetadataMessage, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Classes="panelTitle" Text="Метаданные" />
|
||||
<TextBlock Classes="cardMeta" TextWrapping="Wrap" Text="{Binding MetadataMessage}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Matches}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MetadataMatchViewModel">
|
||||
<Border Background="{DynamicResource CardBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
Padding="10,8"
|
||||
Margin="0,0,0,8">
|
||||
<StackPanel Spacing="5">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
FontSize="12.5"
|
||||
FontWeight="SemiBold"
|
||||
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}}" />
|
||||
<TextBlock Classes="cardMeta"
|
||||
TextWrapping="Wrap"
|
||||
MaxLines="4"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding Description}"
|
||||
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
|
||||
<Button HorizontalAlignment="Left"
|
||||
Command="{Binding ApplyCommand}"
|
||||
Content="Применить" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8"
|
||||
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Classes="panelTitle" Text="Описание" />
|
||||
<TextBlock Text="{Binding Description}"
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding Performers.Count}">
|
||||
<TextBlock Classes="panelTitle" Text="Актёры" />
|
||||
<ItemsControl ItemsSource="{Binding Performers}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding Studios.Count}">
|
||||
<TextBlock Classes="panelTitle" Text="Студии" />
|
||||
<ItemsControl ItemsSource="{Binding Studios}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="panelTitle" Text="Теги" />
|
||||
<ItemsControl ItemsSource="{Binding Tags}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"MaxIndexingConcurrency": 4,
|
||||
"MinimumFileSizeInBytes": 65536
|
||||
},
|
||||
"Metadata": {
|
||||
"Sources": []
|
||||
},
|
||||
"Appearance": {
|
||||
"Theme": "Dark"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user