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="Сведения и метки"> +