From eb4894428bd15aaa2b83b0406e68faf475780491 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 9 Aug 2026 08:18:40 +0300 Subject: [PATCH] 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. --- Directory.Packages.props | 1 + README.md | 35 +- .../Abstractions/IMetadataProvider.cs | 17 + .../Library/ILibraryService.cs | 19 + .../Library/LibraryService.cs | 98 +++++ .../Metadata/MetadataOptions.cs | 42 ++ .../Metadata/VideoMetadataMatch.cs | 34 ++ .../Services/JsonAppSettingsStore.cs | 24 +- src/PLib.Desktop/Settings/AppSettings.cs | 8 +- .../ViewModels/MetadataMatchViewModel.cs | 49 +++ .../MetadataSourceEntryViewModel.cs | 51 +++ .../ViewModels/SettingsViewModel.cs | 23 ++ .../ViewModels/VideoPlayerViewModel.cs | 145 ++++++- src/PLib.Desktop/Views/SettingsView.axaml | 51 +++ src/PLib.Desktop/Views/VideoPlayerView.axaml | 88 ++++ src/PLib.Desktop/appsettings.json | 3 + src/PLib.Domain/Videos/LibraryLabel.cs | 16 +- src/PLib.Domain/Videos/VideoItem.cs | 10 + .../DependencyInjection.cs | 18 + .../Metadata/StashBoxMetadataProvider.cs | 168 ++++++++ .../PLib.Infrastructure.csproj | 1 + .../20260809051032_SceneMetadata.Designer.cs | 165 ++++++++ .../20260809051032_SceneMetadata.cs | 28 ++ .../LibraryDbContextModelSnapshot.cs | 3 + .../Persistence/VideoItemConfiguration.cs | 4 + .../Library/DuplicateDetectionTests.cs | 2 + tests/PLib.Tests/Library/LabelTests.cs | 2 + .../PLib.Tests/Library/LibraryServiceTests.cs | 112 ++++- tests/PLib.Tests/Library/MetadataMonitor.cs | 26 ++ .../Settings/AppSettingsStoreTests.cs | 386 ++++++++++-------- .../Settings/SettingsViewModelTests.cs | 7 +- 31 files changed, 1442 insertions(+), 194 deletions(-) create mode 100644 src/PLib.Application/Abstractions/IMetadataProvider.cs create mode 100644 src/PLib.Application/Metadata/MetadataOptions.cs create mode 100644 src/PLib.Application/Metadata/VideoMetadataMatch.cs create mode 100644 src/PLib.Desktop/ViewModels/MetadataMatchViewModel.cs create mode 100644 src/PLib.Desktop/ViewModels/MetadataSourceEntryViewModel.cs create mode 100644 src/PLib.Infrastructure/Metadata/StashBoxMetadataProvider.cs create mode 100644 src/PLib.Infrastructure/Persistence/Migrations/20260809051032_SceneMetadata.Designer.cs create mode 100644 src/PLib.Infrastructure/Persistence/Migrations/20260809051032_SceneMetadata.cs create mode 100644 tests/PLib.Tests/Library/MetadataMonitor.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index d85cb14..bcc191a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -28,6 +28,7 @@ + diff --git a/README.md b/README.md index ac7a1de..9735c8b 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ в `settings.json` и подхватывается без перезапуска. - Очистка собранных данных по видам — постеры, анимированные превью, отпечатки, технические метаданные — каждый со своей кнопкой и текущим объёмом. +- Источники метаданных: список GraphQL-эндпойнтов (название, адрес, API-ключ) со схемой + stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается + списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия. - Светлая, тёмная и системная темы; выбор запоминается. - Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео, перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход — @@ -128,12 +131,29 @@ dotnet test системой непересекающихся множеств, чтобы цепочка «A похож на B, B на C» дала одну группу. **Побитовая совместимость со stash не проверена** — разные реализации ресайза способны перевернуть биты у коэффициентов рядом с медианой. -- **Теги и коллекции — одна сущность.** `LibraryLabel` с `LabelKind`: связь с видео у них - одинаковая, различается только назначение. Одна сущность — одна таблица связей, один - репозиторий и одно правило именования; разделить потом можно переименованием и миграцией, - а держать два почти одинаковых агрегата синхронными пришлось бы всегда. Уникальность — - по нормализованному имени в паре с видом, так что «Комедия» и «комедия» не разойдутся, - а тег и коллекция с одним именем сосуществуют. +- **Теги, коллекции, актёры и студии — одна сущность.** `LibraryLabel` с `LabelKind`: связь + с видео у них одинаковая, различается только назначение. Одна сущность — одна таблица + связей, один репозиторий и одно правило именования; разделить потом можно переименованием + и миграцией, а держать четыре почти одинаковых агрегата синхронными пришлось бы всегда. + Появление актёров и студий это подтвердило: два новых значения перечисления, ноль новых + таблиц. Уникальность — по нормализованному имени в паре с видом, так что «Комедия» и + «комедия» не разойдутся, а тег и студия с одним именем сосуществуют. +- **Метаданные — только по кнопке.** Никакой фоновой синхронизации: обращение к чужому + серверу по поводу файлов пользователя происходит тогда, когда он нажал «Найти метаданные», + и больше никогда. Уходит один отпечаток — 16 шестнадцатеричных цифр; ни имён файлов, ни + самих файлов. + Источники опрашиваются по очереди и независимо: упавший попадает в список «не ответили», + но не прячет то, что нашли остальные. GraphQL отвечает двухсотым и массивом `errors`, + поэтому он разбирается явно — иначе неверный ключ читался бы как «источник ничего не знает». + Найденное не применяется само: отпечатки совпадают у перекодировок и трейлеров, а молча + переписанное название откатывать куда дороже, чем нажать кнопку. Применение добавляет метки, + но не удаляет чужие — то, что проставил пользователь, остаётся. + Схема — stash-box (StashDB и родственники): именно поэтому список источников вообще имеет + смысл, ведь это разные экземпляры одного сервера, отвечающие на один и тот же запрос. + GraphQL-клиента в зависимостях нет: весь разговор — один POST с `{query, variables}` и один + объект в ответе. + **API-ключи лежат в `settings.json` открытым текстом** — там же и с той же защитой, что и + остальные настройки, то есть правами файловой системы. - **Наблюдатель говорит только «посмотри снова».** `FileSystemWatcher` шлёт несколько событий на файл, а копирование — поток событий на всё время копирования. Восстанавливать из этого точную дельту — гадание, поэтому события гасятся тремя секундами тишины, а @@ -161,7 +181,8 @@ dotnet test - `library.db` — SQLite с метаданными, метками, прогрессом просмотра и pHash; - `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла); - `previews/` — кэш анимированных превью, тот же ключ плюс число кадров в имени; -- `settings.json` — папки, параметры превью и сканирования, тема, громкость; +- `settings.json` — папки, параметры превью и сканирования, тема, громкость, источники + метаданных вместе с их API-ключами; перечитывается на лету; - `logs/` — Serilog, ротация по дням. diff --git a/src/PLib.Application/Abstractions/IMetadataProvider.cs b/src/PLib.Application/Abstractions/IMetadataProvider.cs new file mode 100644 index 0000000..a16ddc7 --- /dev/null +++ b/src/PLib.Application/Abstractions/IMetadataProvider.cs @@ -0,0 +1,17 @@ +using PLib.Application.Metadata; + +namespace PLib.Application.Abstractions; + +/// Asks a configured source what it knows about a video, by fingerprint. +public interface IMetadataProvider +{ + /// + /// Everything has for the given perceptual hash. An empty list + /// means the source answered and knows nothing; a failure to reach or understand it is + /// thrown, because those two outcomes call for different things from the user. + /// + Task> FindByPerceptualHashAsync( + MetadataSourceOptions source, + ulong perceptualHash, + CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Application/Library/ILibraryService.cs b/src/PLib.Application/Library/ILibraryService.cs index 895e128..89cc1d3 100644 --- a/src/PLib.Application/Library/ILibraryService.cs +++ b/src/PLib.Application/Library/ILibraryService.cs @@ -1,3 +1,4 @@ +using PLib.Application.Metadata; using PLib.Domain.Videos; namespace PLib.Application.Library; @@ -61,4 +62,22 @@ public interface ILibraryService CancellationToken cancellationToken = default); Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default); + + /// + /// Asks every configured metadata source what it has for this video's fingerprint. + /// + /// + /// Nothing calls this on its own: reaching out to a remote service about the user's files + /// happens when the user presses the button, and at no other time. + /// + Task FindMetadataAsync(Guid videoId, CancellationToken cancellationToken = default); + + /// + /// Writes a match onto the video: title, description, and a label per tag, performer and + /// studio. Labels already on the video are kept — applying a match adds, never prunes. + /// + Task ApplyMetadataAsync( + Guid videoId, + VideoMetadataMatch match, + CancellationToken cancellationToken = default); } diff --git a/src/PLib.Application/Library/LibraryService.cs b/src/PLib.Application/Library/LibraryService.cs index 60ceb91..d57f2be 100644 --- a/src/PLib.Application/Library/LibraryService.cs +++ b/src/PLib.Application/Library/LibraryService.cs @@ -3,6 +3,7 @@ using System.Threading.Channels; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using PLib.Application.Abstractions; +using PLib.Application.Metadata; using PLib.Domain.Videos; namespace PLib.Application.Library; @@ -16,7 +17,9 @@ public sealed class LibraryService( IThumbnailGenerator thumbnailGenerator, IAnimatedPreviewGenerator previewGenerator, IVideoPerceptualHasher perceptualHasher, + IMetadataProvider metadataProvider, IOptions options, + IOptionsMonitor metadataOptions, ILogger logger) : ILibraryService { /// How many indexed items to accumulate before flushing them to storage. @@ -147,6 +150,101 @@ public sealed class LibraryService( } } + public async Task FindMetadataAsync( + Guid videoId, + CancellationToken cancellationToken = default) + { + var video = await repository.FindWithLabelsAsync(videoId, cancellationToken) + ?? throw new InvalidOperationException($"Video {videoId} is not in the library"); + + if (video.PerceptualHash is not { } hash) + { + return new MetadataLookupResult(HasPerceptualHash: false, [], []); + } + + var matches = new List(); + var failures = new List(); + + // Sequentially and in configured order: the list is short, the sources are somebody + // else's servers, and a predictable order is worth more here than a few saved seconds. + foreach (var source in metadataOptions.CurrentValue.Sources.Where(source => source.IsUsable)) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + matches.AddRange( + await metadataProvider.FindByPerceptualHashAsync(source, hash, cancellationToken)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // One source being down must not hide what the others found. + logger.LogWarning(ex, "Metadata source {Source} could not be queried", source.Name); + failures.Add($"{source.Name}: {ex.Message}"); + } + } + + return new MetadataLookupResult(HasPerceptualHash: true, matches, failures); + } + + public async Task ApplyMetadataAsync( + Guid videoId, + VideoMetadataMatch match, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(match); + + var video = await repository.FindWithLabelsAsync(videoId, cancellationToken) + ?? throw new InvalidOperationException($"Video {videoId} is not in the library"); + + if (!string.IsNullOrWhiteSpace(match.Title)) + { + video.Rename(match.Title); + } + + video.Describe(match.Description); + + await AttachAllAsync(video, match.Tags, LabelKind.Tag, cancellationToken); + await AttachAllAsync(video, match.Performers, LabelKind.Performer, cancellationToken); + await AttachAllAsync(video, match.Studios, LabelKind.Studio, cancellationToken); + + // One save for the whole match: half an applied match is worse than none, because + // nothing on screen would say which half. + await repository.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Applied metadata from {Source} to {Video}", match.SourceName, video.Title); + } + + private async Task AttachAllAsync( + VideoItem video, + IEnumerable names, + LabelKind kind, + CancellationToken cancellationToken) + { + // A source can repeat a name within one match, and the video may already carry it; + // both have to collapse onto a single label. + var distinct = names + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.CurrentCultureIgnoreCase); + + foreach (var name in distinct) + { + var label = await labels.FindAsync(kind, name, cancellationToken); + + if (label is null) + { + label = new LibraryLabel(name, kind); + await labels.AddAsync(label, cancellationToken); + } + + video.AddLabel(label); + } + } + public async Task> GetDataUsageAsync( CancellationToken cancellationToken = default) { diff --git a/src/PLib.Application/Metadata/MetadataOptions.cs b/src/PLib.Application/Metadata/MetadataOptions.cs new file mode 100644 index 0000000..4958645 --- /dev/null +++ b/src/PLib.Application/Metadata/MetadataOptions.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace PLib.Application.Metadata; + +/// Where PLib may go looking for information about a video it has on disk. +public sealed class MetadataOptions +{ + public const string SectionName = "Metadata"; + + /// + /// The configured sources, in the order they were added. Empty by default: talking to a + /// remote service about the user's files is something they have to ask for. + /// + public IList Sources { get; init; } = []; +} + +/// One GraphQL endpoint that can be asked about a video. +public sealed class MetadataSourceOptions +{ + /// What to call it in the interface; the endpoint is no use as a label. + [Required] + public string Name { get; init; } = string.Empty; + + /// Absolute URL of the GraphQL endpoint. + [Required] + public string Endpoint { get; init; } = string.Empty; + + /// + /// Sent as the ApiKey header. Stored in the settings file in plain text — the same + /// place and the same protection the rest of the settings get, which is the file system's. + /// + public string ApiKey { get; init; } = string.Empty; + + /// False for a source the user has switched off without deleting it. + public bool IsEnabled { get; init; } = true; + + /// True when this entry has enough filled in to be worth calling. + public bool IsUsable => + IsEnabled && + !string.IsNullOrWhiteSpace(Endpoint) && + Uri.TryCreate(Endpoint, UriKind.Absolute, out _); +} diff --git a/src/PLib.Application/Metadata/VideoMetadataMatch.cs b/src/PLib.Application/Metadata/VideoMetadataMatch.cs new file mode 100644 index 0000000..be13b7a --- /dev/null +++ b/src/PLib.Application/Metadata/VideoMetadataMatch.cs @@ -0,0 +1,34 @@ +namespace PLib.Application.Metadata; + +/// +/// One candidate a source returned for a video, as PLib understands it. +/// +/// +/// Deliberately flat and free of anything source-specific: a match is a proposal shown to the +/// user, and whichever schema it was decoded from stops mattering the moment it is decoded. +/// +/// Which configured source proposed it. +/// Its identifier at the source, for display and for reporting. +public sealed record VideoMetadataMatch( + string SourceName, + string? RemoteId, + string Title, + string? Description, + IReadOnlyList Tags, + IReadOnlyList Performers, + IReadOnlyList Studios); + +/// Everything one lookup produced, including what went wrong. +/// +/// False when the video has no fingerprint yet, which is the one failure the user can act on: +/// the answer is to let the scan finish, not to check the sources. +/// +/// Candidates from every source that answered. +/// +/// One line per source that could not be reached or did not understand the question. Sources +/// are independent, so one being down must not hide the answers from the others. +/// +public sealed record MetadataLookupResult( + bool HasPerceptualHash, + IReadOnlyList Matches, + IReadOnlyList Failures); diff --git a/src/PLib.Desktop/Services/JsonAppSettingsStore.cs b/src/PLib.Desktop/Services/JsonAppSettingsStore.cs index d7c19f5..4ed8870 100644 --- a/src/PLib.Desktop/Services/JsonAppSettingsStore.cs +++ b/src/PLib.Desktop/Services/JsonAppSettingsStore.cs @@ -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 library, IOptionsMonitor appearance, - IOptionsMonitor playback) : IAppSettingsStore + IOptionsMonitor playback, + IOptionsMonitor 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); diff --git a/src/PLib.Desktop/Settings/AppSettings.cs b/src/PLib.Desktop/Settings/AppSettings.cs index 8309c11..87a1132 100644 --- a/src/PLib.Desktop/Settings/AppSettings.cs +++ b/src/PLib.Desktop/Settings/AppSettings.cs @@ -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; } + /// GraphQL endpoints that may be asked about a video, in the order shown. + public required IReadOnlyList 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], }; /// diff --git a/src/PLib.Desktop/ViewModels/MetadataMatchViewModel.cs b/src/PLib.Desktop/ViewModels/MetadataMatchViewModel.cs new file mode 100644 index 0000000..bc9e788 --- /dev/null +++ b/src/PLib.Desktop/ViewModels/MetadataMatchViewModel.cs @@ -0,0 +1,49 @@ +using PLib.Application.Metadata; +using ReactiveUI; +using RxVoid = ReactiveUI.Primitives.RxVoid; + +namespace PLib.Desktop.ViewModels; + +/// One candidate returned by a metadata source, offered for the user to accept. +/// +/// 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. +/// +public sealed class MetadataMatchViewModel +{ + public MetadataMatchViewModel(VideoMetadataMatch match, Func 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; } + + /// Comma-separated for display; the lists themselves stay on . + public string? Studios { get; } + + public string? Performers { get; } + + public string? Tags { get; } + + public ReactiveCommand ApplyCommand { get; } + + private static string? Join(IReadOnlyList values) => + values.Count == 0 ? null : string.Join(", ", values); +} diff --git a/src/PLib.Desktop/ViewModels/MetadataSourceEntryViewModel.cs b/src/PLib.Desktop/ViewModels/MetadataSourceEntryViewModel.cs new file mode 100644 index 0000000..04dcefe --- /dev/null +++ b/src/PLib.Desktop/ViewModels/MetadataSourceEntryViewModel.cs @@ -0,0 +1,51 @@ +using PLib.Application.Metadata; +using ReactiveUI; +using ReactiveUI.SourceGenerators; +using RxVoid = ReactiveUI.Primitives.RxVoid; + +namespace PLib.Desktop.ViewModels; + +/// One metadata source being edited in the settings panel. +/// +/// 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. +/// +public sealed partial class MetadataSourceEntryViewModel : ReactiveObject +{ + public MetadataSourceEntryViewModel( + MetadataSourceOptions source, + Action 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; } + + /// Lets a source be silenced without losing its endpoint and key. + [Reactive] + public partial bool IsEnabled { get; set; } + + public ReactiveCommand RemoveCommand { get; } + + public MetadataSourceOptions ToOptions() => new() + { + Name = Name?.Trim() ?? string.Empty, + Endpoint = Endpoint?.Trim() ?? string.Empty, + ApiKey = ApiKey?.Trim() ?? string.Empty, + IsEnabled = IsEnabled, + }; +} diff --git a/src/PLib.Desktop/ViewModels/SettingsViewModel.cs b/src/PLib.Desktop/ViewModels/SettingsViewModel.cs index 608bd90..bb1f175 100644 --- a/src/PLib.Desktop/ViewModels/SettingsViewModel.cs +++ b/src/PLib.Desktop/ViewModels/SettingsViewModel.cs @@ -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 MetadataSources { get; } + + public ReactiveCommand AddMetadataSourceCommand { get; } + public IReadOnlyList ThemeOptions { get; } = ThemeOption.All; public ReactiveCommand 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( [ AddFolderCommand.ThrownExceptions, + AddMetadataSourceCommand.ThrownExceptions, RefreshUsageCommand.ThrownExceptions, ClearAllCommand.ThrownExceptions, SaveCommand.ThrownExceptions, diff --git a/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs index 1aeb2c0..3881eaa 100644 --- a/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs +++ b/src/PLib.Desktop/ViewModels/VideoPlayerViewModel.cs @@ -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; } + /// Reactive because applying a match renames the video under the open page. + [Reactive] + public partial string Title { get; set; } public string FullPath { get; } @@ -107,6 +116,13 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase public ObservableCollection Collections { get; } = []; + public ObservableCollection Performers { get; } = []; + + public ObservableCollection Studios { get; } = []; + + /// Candidates from the last lookup; empty until the button is pressed. + public ObservableCollection Matches { get; } = []; + public ReactiveCommand CloseCommand { get; } public ReactiveCommand ToggleFullScreenCommand { get; } @@ -125,6 +141,19 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase public ReactiveCommand LoadLabelsCommand { get; } + public ReactiveCommand LookupMetadataCommand { get; } + + [Reactive] + public partial bool IsLookingUp { get; set; } + + /// What the last lookup came to, including which sources failed. + [Reactive] + public partial string? MetadataMessage { get; set; } + + /// Free text about the video, once a match has supplied one. + [Reactive] + public partial string? Description { get; set; } + /// /// 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(); + + 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; + } + } + + /// + /// 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. + /// + 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)}"; + } + + /// + /// 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. + /// + private async Task ApplyMetadataAsync(VideoMetadataMatch match) + { + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var library = scope.ServiceProvider.GetRequiredService(); + + 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 Target(LabelKind kind) => - kind == LabelKind.Tag ? Tags : Collections; + private ObservableCollection 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); } diff --git a/src/PLib.Desktop/Views/SettingsView.axaml b/src/PLib.Desktop/Views/SettingsView.axaml index 9cfbec3..806cb30 100644 --- a/src/PLib.Desktop/Views/SettingsView.axaml +++ b/src/PLib.Desktop/Views/SettingsView.axaml @@ -135,6 +135,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PLib.Desktop/Views/VideoPlayerView.axaml b/src/PLib.Desktop/Views/VideoPlayerView.axaml index 4d95e7c..4c63f18 100644 --- a/src/PLib.Desktop/Views/VideoPlayerView.axaml +++ b/src/PLib.Desktop/Views/VideoPlayerView.axaml @@ -72,6 +72,11 @@ ToolTip.Tip="Сведения и метки"> +