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="Сведения и метки">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PLib.Desktop/appsettings.json b/src/PLib.Desktop/appsettings.json
index 1d4f14c..de7d2dd 100644
--- a/src/PLib.Desktop/appsettings.json
+++ b/src/PLib.Desktop/appsettings.json
@@ -8,6 +8,9 @@
"MaxIndexingConcurrency": 4,
"MinimumFileSizeInBytes": 65536
},
+ "Metadata": {
+ "Sources": []
+ },
"Appearance": {
"Theme": "Dark"
},
diff --git a/src/PLib.Domain/Videos/LibraryLabel.cs b/src/PLib.Domain/Videos/LibraryLabel.cs
index 696fbac..4b24dd8 100644
--- a/src/PLib.Domain/Videos/LibraryLabel.cs
+++ b/src/PLib.Domain/Videos/LibraryLabel.cs
@@ -8,17 +8,23 @@ public enum LabelKind
/// A named group the user curates and browses as a whole.
Collection,
+
+ /// Someone who appears in the video.
+ Performer,
+
+ /// Who produced it.
+ Studio,
}
///
/// A named grouping of videos — a tag or a collection.
///
///
-/// Tags and collections are the same relation: a name, many videos, a video in many of them.
-/// They differ only in intent, and that intent is . One entity means one
-/// join table, one repository and one set of rules about naming; splitting them later is a
-/// rename and a migration, whereas keeping two near-identical aggregates in sync from the
-/// start is a permanent tax.
+/// Tags, collections, performers and studios are the same relation: a name, many videos, a
+/// video in many of them. They differ only in intent, and that intent is .
+/// One entity means one join table, one repository and one set of rules about naming;
+/// splitting them later is a rename and a migration, whereas keeping four near-identical
+/// aggregates in sync from the start is a permanent tax.
///
public sealed class LibraryLabel
{
diff --git a/src/PLib.Domain/Videos/VideoItem.cs b/src/PLib.Domain/Videos/VideoItem.cs
index 4fede3a..ff12be2 100644
--- a/src/PLib.Domain/Videos/VideoItem.cs
+++ b/src/PLib.Domain/Videos/VideoItem.cs
@@ -51,6 +51,12 @@ public sealed class VideoItem
/// Human readable name; defaults to the file name without extension.
public string Title { get; private set; }
+ ///
+ /// Free text about the video. Never derived from the file — it only arrives from a
+ /// metadata source or from the user, which is why refreshing the file leaves it alone.
+ ///
+ public string? Description { get; private set; }
+
public long SizeInBytes { get; private set; }
public TimeSpan? Duration { get; private set; }
@@ -133,6 +139,10 @@ public sealed class VideoItem
Title = title;
}
+ /// Replaces the description; blank clears it rather than storing whitespace.
+ public void Describe(string? description) =>
+ Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
+
public void ApplyTechnicalInfo(VideoTechnicalInfo info)
{
Duration = info.Duration;
diff --git a/src/PLib.Infrastructure/DependencyInjection.cs b/src/PLib.Infrastructure/DependencyInjection.cs
index a53b6bb..09877b8 100644
--- a/src/PLib.Infrastructure/DependencyInjection.cs
+++ b/src/PLib.Infrastructure/DependencyInjection.cs
@@ -4,7 +4,9 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using PLib.Application.Abstractions;
using PLib.Application.Library;
+using PLib.Application.Metadata;
using PLib.Infrastructure.Media;
+using PLib.Infrastructure.Metadata;
using PLib.Infrastructure.Persistence;
using PLib.Infrastructure.Storage;
@@ -25,6 +27,12 @@ public static class DependencyInjection
.ValidateDataAnnotations()
.ValidateOnStart();
+ // Deliberately not validated on start: a half-filled source is something the user is
+ // in the middle of typing, and refusing to launch over it would be absurd. Whether an
+ // entry is worth calling is decided when it is called.
+ services.AddOptions()
+ .Bind(configuration.GetSection(MetadataOptions.SectionName));
+
// TryAdd so a composition root that already needed the paths (to locate the user
// settings file before the container exists) can share its own instance.
services.TryAddSingleton();
@@ -43,6 +51,16 @@ public static class DependencyInjection
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+
+ // A short timeout on purpose: this runs behind a button the user is waiting on, and a
+ // source that has not answered in fifteen seconds is better reported than waited for.
+ services.AddHttpClient(StashBoxMetadataProvider.HttpClientName, client =>
+ {
+ client.Timeout = TimeSpan.FromSeconds(15);
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("PLib/1.0");
+ });
+
+ services.AddSingleton();
services.AddScoped();
services.AddHostedService();
diff --git a/src/PLib.Infrastructure/Metadata/StashBoxMetadataProvider.cs b/src/PLib.Infrastructure/Metadata/StashBoxMetadataProvider.cs
new file mode 100644
index 0000000..24907f4
--- /dev/null
+++ b/src/PLib.Infrastructure/Metadata/StashBoxMetadataProvider.cs
@@ -0,0 +1,168 @@
+using System.Globalization;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using PLib.Application.Abstractions;
+using PLib.Application.Metadata;
+
+namespace PLib.Infrastructure.Metadata;
+
+///
+/// Looks a video up by perceptual hash against a stash-box GraphQL endpoint.
+///
+///
+/// stash-box is the schema the configurable-endpoint-plus-key arrangement exists for: StashDB
+/// and its siblings are separate instances of one server, each with its own address and its
+/// own key, and all of them answer the same query. That is what makes a list of sources
+/// meaningful — a list of endpoints speaking unrelated schemas could not share one query.
+///
+/// No GraphQL client library: the whole conversation is one POST of {query, variables}
+/// and one object to read out of the reply, and a dependency to build that string would be
+/// larger than the code it replaced.
+///
+public sealed class StashBoxMetadataProvider(
+ IHttpClientFactory httpClientFactory,
+ ILogger logger) : IMetadataProvider
+{
+ /// Name of the configured ; see the DI registration.
+ public const string HttpClientName = "metadata";
+
+ private const string Query = """
+ query FindSceneByFingerprint($hash: String!) {
+ findSceneByFingerprint(fingerprint: { hash: $hash, algorithm: PHASH }) {
+ id
+ title
+ details
+ studio { name }
+ tags { name }
+ performers { performer { name } }
+ }
+ }
+ """;
+
+ public async Task> FindByPerceptualHashAsync(
+ MetadataSourceOptions source,
+ ulong perceptualHash,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, source.Endpoint)
+ {
+ // stash and stash-box both hash to a 16-digit lower-case hex string, so the
+ // fingerprint travels in the form the far end already stores it in.
+ Content = JsonContent.Create(new
+ {
+ query = Query,
+ variables = new { hash = perceptualHash.ToString("x16", CultureInfo.InvariantCulture) },
+ }),
+ };
+
+ if (!string.IsNullOrWhiteSpace(source.ApiKey))
+ {
+ request.Headers.TryAddWithoutValidation("ApiKey", source.ApiKey);
+ }
+
+ var client = httpClientFactory.CreateClient(HttpClientName);
+
+ using var response = await client.SendAsync(request, cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new HttpRequestException(
+ $"{(int)response.StatusCode} {response.ReasonPhrase}",
+ inner: null,
+ response.StatusCode);
+ }
+
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
+
+ ThrowOnGraphQlErrors(document.RootElement, source.Name);
+
+ if (!document.RootElement.TryGetProperty("data", out var data) ||
+ !data.TryGetProperty("findSceneByFingerprint", out var scenes) ||
+ scenes.ValueKind != JsonValueKind.Array)
+ {
+ logger.LogDebug("Source {Source} returned no scenes element", source.Name);
+ return [];
+ }
+
+ return [.. scenes.EnumerateArray().Select(scene => ReadScene(scene, source.Name))];
+ }
+
+ ///
+ /// A GraphQL server answers 200 with an errors array, so a bad key or a schema
+ /// mismatch would otherwise read as "this source knows nothing about your video".
+ ///
+ private static void ThrowOnGraphQlErrors(JsonElement root, string sourceName)
+ {
+ if (!root.TryGetProperty("errors", out var errors) ||
+ errors.ValueKind != JsonValueKind.Array ||
+ errors.GetArrayLength() == 0)
+ {
+ return;
+ }
+
+ var messages = errors
+ .EnumerateArray()
+ .Select(error => error.TryGetProperty("message", out var message)
+ ? message.GetString()
+ : null)
+ .Where(message => !string.IsNullOrWhiteSpace(message));
+
+ throw new InvalidOperationException($"{sourceName}: {string.Join("; ", messages)}");
+ }
+
+ private static VideoMetadataMatch ReadScene(JsonElement scene, string sourceName) => new(
+ sourceName,
+ Text(scene, "id"),
+ Text(scene, "title") ?? "Без названия",
+ Text(scene, "details"),
+ Names(scene, "tags"),
+ Performers(scene),
+ Studios(scene));
+
+ private static string? Text(JsonElement element, string property) =>
+ element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
+ ? value.GetString()
+ : null;
+
+ private static IReadOnlyList Names(JsonElement scene, string property)
+ {
+ if (!scene.TryGetProperty(property, out var array) || array.ValueKind != JsonValueKind.Array)
+ {
+ return [];
+ }
+
+ return [.. array.EnumerateArray().Select(item => Text(item, "name")).OfType()];
+ }
+
+ ///
+ /// Performers arrive wrapped in an appearance — the same person can be credited under a
+ /// different name on a given scene — and it is the person's name we want.
+ ///
+ private static IReadOnlyList Performers(JsonElement scene)
+ {
+ if (!scene.TryGetProperty("performers", out var array) || array.ValueKind != JsonValueKind.Array)
+ {
+ return [];
+ }
+
+ return
+ [
+ .. array
+ .EnumerateArray()
+ .Select(appearance => appearance.TryGetProperty("performer", out var performer)
+ ? Text(performer, "name")
+ : null)
+ .OfType()
+ ];
+ }
+
+ /// A scene has at most one studio; the shape is a list because a match may not.
+ private static IReadOnlyList Studios(JsonElement scene) =>
+ scene.TryGetProperty("studio", out var studio) && Text(studio, "name") is { } name
+ ? [name]
+ : [];
+}
diff --git a/src/PLib.Infrastructure/PLib.Infrastructure.csproj b/src/PLib.Infrastructure/PLib.Infrastructure.csproj
index 09e80fe..2bc2e46 100644
--- a/src/PLib.Infrastructure/PLib.Infrastructure.csproj
+++ b/src/PLib.Infrastructure/PLib.Infrastructure.csproj
@@ -13,6 +13,7 @@
+
diff --git a/src/PLib.Infrastructure/Persistence/Migrations/20260809051032_SceneMetadata.Designer.cs b/src/PLib.Infrastructure/Persistence/Migrations/20260809051032_SceneMetadata.Designer.cs
new file mode 100644
index 0000000..a4e89ab
--- /dev/null
+++ b/src/PLib.Infrastructure/Persistence/Migrations/20260809051032_SceneMetadata.Designer.cs
@@ -0,0 +1,165 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using PLib.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace PLib.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(LibraryDbContext))]
+ [Migration("20260809051032_SceneMetadata")]
+ partial class SceneMetadata
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
+
+ modelBuilder.Entity("LibraryLabelVideoItem", b =>
+ {
+ b.Property("LabelsId")
+ .HasColumnType("TEXT");
+
+ b.Property("VideosId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("LabelsId", "VideosId");
+
+ b.HasIndex("VideosId");
+
+ b.ToTable("VideoLabels", (string)null);
+ });
+
+ modelBuilder.Entity("PLib.Domain.Videos.LibraryLabel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Kind", "NormalizedName")
+ .IsUnique();
+
+ b.ToTable("Labels", (string)null);
+ });
+
+ modelBuilder.Entity("PLib.Domain.Videos.VideoItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Description")
+ .HasColumnType("TEXT");
+
+ b.Property("Duration")
+ .HasColumnType("TEXT");
+
+ b.Property("FileModifiedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("FullPath")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("TEXT");
+
+ b.Property("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastPlayedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("PerceptualHash")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("PreviewFrameCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("PreviewPath")
+ .HasMaxLength(1024)
+ .HasColumnType("TEXT");
+
+ b.Property("ResumePosition")
+ .HasColumnType("TEXT");
+
+ b.Property("SizeInBytes")
+ .HasColumnType("INTEGER");
+
+ b.Property("ThumbnailPath")
+ .HasMaxLength(1024)
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("VideoCodec")
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AddedAt");
+
+ b.HasIndex("FullPath")
+ .IsUnique();
+
+ b.HasIndex("LastPlayedAt");
+
+ b.HasIndex("PerceptualHash");
+
+ b.ToTable("Videos", (string)null);
+ });
+
+ modelBuilder.Entity("LibraryLabelVideoItem", b =>
+ {
+ b.HasOne("PLib.Domain.Videos.LibraryLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("PLib.Domain.Videos.VideoItem", null)
+ .WithMany()
+ .HasForeignKey("VideosId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/PLib.Infrastructure/Persistence/Migrations/20260809051032_SceneMetadata.cs b/src/PLib.Infrastructure/Persistence/Migrations/20260809051032_SceneMetadata.cs
new file mode 100644
index 0000000..dcf30b3
--- /dev/null
+++ b/src/PLib.Infrastructure/Persistence/Migrations/20260809051032_SceneMetadata.cs
@@ -0,0 +1,28 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace PLib.Infrastructure.Persistence.Migrations
+{
+ ///
+ public partial class SceneMetadata : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "Description",
+ table: "Videos",
+ type: "TEXT",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "Description",
+ table: "Videos");
+ }
+ }
+}
diff --git a/src/PLib.Infrastructure/Persistence/Migrations/LibraryDbContextModelSnapshot.cs b/src/PLib.Infrastructure/Persistence/Migrations/LibraryDbContextModelSnapshot.cs
index 10dd54a..cfebc4f 100644
--- a/src/PLib.Infrastructure/Persistence/Migrations/LibraryDbContextModelSnapshot.cs
+++ b/src/PLib.Infrastructure/Persistence/Migrations/LibraryDbContextModelSnapshot.cs
@@ -73,6 +73,9 @@ namespace PLib.Infrastructure.Persistence.Migrations
b.Property("AddedAt")
.HasColumnType("INTEGER");
+ b.Property("Description")
+ .HasColumnType("TEXT");
+
b.Property("Duration")
.HasColumnType("TEXT");
diff --git a/src/PLib.Infrastructure/Persistence/VideoItemConfiguration.cs b/src/PLib.Infrastructure/Persistence/VideoItemConfiguration.cs
index 22e6134..4d623fc 100644
--- a/src/PLib.Infrastructure/Persistence/VideoItemConfiguration.cs
+++ b/src/PLib.Infrastructure/Persistence/VideoItemConfiguration.cs
@@ -50,6 +50,10 @@ internal sealed class VideoItemConfiguration : IEntityTypeConfiguration x.Description);
+
builder.Property(x => x.VideoCodec)
.HasMaxLength(64);
diff --git a/tests/PLib.Tests/Library/DuplicateDetectionTests.cs b/tests/PLib.Tests/Library/DuplicateDetectionTests.cs
index 5c7c536..64c65f5 100644
--- a/tests/PLib.Tests/Library/DuplicateDetectionTests.cs
+++ b/tests/PLib.Tests/Library/DuplicateDetectionTests.cs
@@ -91,6 +91,8 @@ public sealed class DuplicateDetectionTests
Substitute.For(),
Substitute.For(),
Substitute.For(),
+ Substitute.For(),
Options.Create(new LibraryOptions()),
+ MetadataMonitor.Empty,
NullLogger.Instance);
}
diff --git a/tests/PLib.Tests/Library/LabelTests.cs b/tests/PLib.Tests/Library/LabelTests.cs
index fc352a6..e2587c0 100644
--- a/tests/PLib.Tests/Library/LabelTests.cs
+++ b/tests/PLib.Tests/Library/LabelTests.cs
@@ -77,6 +77,8 @@ public sealed class LabelTests
Substitute.For(),
Substitute.For(),
Substitute.For(),
+ Substitute.For(),
Options.Create(new LibraryOptions()),
+ MetadataMonitor.Empty,
NullLogger.Instance);
}
diff --git a/tests/PLib.Tests/Library/LibraryServiceTests.cs b/tests/PLib.Tests/Library/LibraryServiceTests.cs
index 6cfc9f8..cd2ea35 100644
--- a/tests/PLib.Tests/Library/LibraryServiceTests.cs
+++ b/tests/PLib.Tests/Library/LibraryServiceTests.cs
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Options;
using NSubstitute;
using PLib.Application.Abstractions;
using PLib.Application.Library;
+using PLib.Application.Metadata;
using PLib.Domain.Videos;
using Shouldly;
@@ -13,11 +14,13 @@ public sealed class LibraryServiceTests
private const string Root = @"C:\videos";
private readonly InMemoryVideoRepository _repository = new();
+ private readonly InMemoryLabelRepository _labels = new();
private readonly IVideoFileScanner _scanner = Substitute.For();
private readonly IMediaProbe _probe = Substitute.For();
private readonly IThumbnailGenerator _thumbnails = Substitute.For();
private readonly IAnimatedPreviewGenerator _previews = Substitute.For();
private readonly IVideoPerceptualHasher _hasher = Substitute.For();
+ private readonly IMetadataProvider _metadata = Substitute.For();
public LibraryServiceTests()
{
@@ -298,6 +301,107 @@ public sealed class LibraryServiceTests
usage[LibraryDataKind.PerceptualHashes].Bytes.ShouldBe(0);
}
+ [Fact]
+ public async Task A_video_without_a_fingerprint_cannot_be_looked_up()
+ {
+ var item = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
+ _repository.Seed(item);
+
+ var result = await CreateService(sources: MetadataMonitor.With(Source("StashDB")))
+ .FindMetadataAsync(item.Id, Token);
+
+ // The answer is "wait for the scan", not "check your sources", so no source is called.
+ result.HasPerceptualHash.ShouldBeFalse();
+ await _metadata.DidNotReceive().FindByPerceptualHashAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task A_source_that_fails_does_not_hide_what_the_others_found()
+ {
+ var item = FullyIndexed();
+ _repository.Seed(item);
+
+ var good = Source("Хороший");
+ var bad = Source("Сломанный");
+
+ _metadata.FindByPerceptualHashAsync(good, Arg.Any(), Arg.Any())
+ .Returns([Match("Сцена", good.Name)]);
+ _metadata.FindByPerceptualHashAsync(bad, Arg.Any(), Arg.Any())
+ .Returns>(_ => throw new HttpRequestException("401"));
+
+ var result = await CreateService(sources: MetadataMonitor.With(bad, good)).FindMetadataAsync(item.Id, Token);
+
+ result.Matches.Single().Title.ShouldBe("Сцена");
+ result.Failures.ShouldHaveSingleItem().ShouldContain("Сломанный");
+ }
+
+ [Fact]
+ public async Task Sources_that_are_switched_off_or_half_filled_are_not_called()
+ {
+ var item = FullyIndexed();
+ _repository.Seed(item);
+
+ var disabled = new MetadataSourceOptions { Name = "Выключен", Endpoint = "https://a/graphql", IsEnabled = false };
+ var blank = new MetadataSourceOptions { Name = "Недописан", Endpoint = " " };
+
+ await CreateService(sources: MetadataMonitor.With(disabled, blank)).FindMetadataAsync(item.Id, Token);
+
+ await _metadata.DidNotReceive().FindByPerceptualHashAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any());
+ }
+
+ [Fact]
+ public async Task Applying_a_match_writes_the_text_and_a_label_of_the_right_kind_for_each_name()
+ {
+ var item = FullyIndexed();
+ _repository.Seed(item);
+
+ var match = new VideoMetadataMatch(
+ "StashDB",
+ "scene-1",
+ "Настоящее название",
+ "Описание",
+ Tags: ["Драма", "драма"],
+ Performers: ["Актёр Один"],
+ Studios: ["Студия"]);
+
+ await CreateService().ApplyMetadataAsync(item.Id, match, Token);
+
+ item.Title.ShouldBe("Настоящее название");
+ item.Description.ShouldBe("Описание");
+
+ // The two spellings of the tag are one label: names are matched case-insensitively.
+ item.Labels.Count(label => label.Kind == LabelKind.Tag).ShouldBe(1);
+ item.Labels.Single(label => label.Kind == LabelKind.Performer).Name.ShouldBe("Актёр Один");
+ item.Labels.Single(label => label.Kind == LabelKind.Studio).Name.ShouldBe("Студия");
+ }
+
+ [Fact]
+ public async Task Applying_a_match_adds_to_the_labels_already_on_the_video()
+ {
+ var item = FullyIndexed();
+ _repository.Seed(item);
+
+ var service = CreateService();
+ await service.AttachLabelAsync(item.Id, "Моё", LabelKind.Tag, Token);
+
+ await service.ApplyMetadataAsync(item.Id, Match("Название", "StashDB") with { Tags = ["Их"] }, Token);
+
+ // A match is a proposal, not a replacement: what the user put there stays.
+ item.Labels.Select(label => label.Name).ShouldBe(["Моё", "Их"], ignoreOrder: true);
+ }
+
+ private static MetadataSourceOptions Source(string name) =>
+ new() { Name = name, Endpoint = $"https://{name}.example/graphql", ApiKey = "key" };
+
+ private static VideoMetadataMatch Match(string title, string sourceName) =>
+ new(sourceName, "id", title, null, [], [], []);
+
private static CancellationToken Token => TestContext.Current.CancellationToken;
private static VideoItem FullyIndexed()
@@ -317,15 +421,19 @@ public sealed class LibraryServiceTests
_scanner.ScanAsync(Arg.Any(), Arg.Any())
.Returns(_ => files.ToAsyncEnumerable());
- private LibraryService CreateService(LibraryOptions? options = null) => new(
+ private LibraryService CreateService(
+ LibraryOptions? options = null,
+ MetadataMonitor? sources = null) => new(
_repository,
- new InMemoryLabelRepository(),
+ _labels,
_scanner,
_probe,
_thumbnails,
_previews,
_hasher,
+ _metadata,
Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
+ sources ?? MetadataMonitor.Empty,
NullLogger.Instance);
private static async Task> CollectAsync(
diff --git a/tests/PLib.Tests/Library/MetadataMonitor.cs b/tests/PLib.Tests/Library/MetadataMonitor.cs
new file mode 100644
index 0000000..369f66d
--- /dev/null
+++ b/tests/PLib.Tests/Library/MetadataMonitor.cs
@@ -0,0 +1,26 @@
+using Microsoft.Extensions.Options;
+using PLib.Application.Metadata;
+
+namespace PLib.Tests.Library;
+
+///
+/// A fixed over metadata sources.
+///
+///
+/// The service reads CurrentValue on every lookup, because the user can add a source
+/// without restarting. A substitute would answer null and blow up in the one place that
+/// matters, so the tests hand it a real value instead.
+///
+internal sealed class MetadataMonitor(MetadataOptions value) : IOptionsMonitor
+{
+ public static MetadataMonitor Empty { get; } = new(new MetadataOptions());
+
+ public static MetadataMonitor With(params MetadataSourceOptions[] sources) =>
+ new(new MetadataOptions { Sources = [.. sources] });
+
+ public MetadataOptions CurrentValue { get; } = value;
+
+ public MetadataOptions Get(string? name) => CurrentValue;
+
+ public IDisposable? OnChange(Action listener) => null;
+}
diff --git a/tests/PLib.Tests/Settings/AppSettingsStoreTests.cs b/tests/PLib.Tests/Settings/AppSettingsStoreTests.cs
index 1244457..e248046 100644
--- a/tests/PLib.Tests/Settings/AppSettingsStoreTests.cs
+++ b/tests/PLib.Tests/Settings/AppSettingsStoreTests.cs
@@ -1,166 +1,220 @@
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.Options;
-using NSubstitute;
-using PLib.Application.Library;
-using PLib.Desktop.Services;
-using PLib.Desktop.Settings;
-using PLib.Infrastructure.Storage;
-using Shouldly;
-
-namespace PLib.Tests.Settings;
-
-///
-/// The settings file is not just storage — it is a live configuration source. These tests
-/// close the loop: what the store writes has to be what the options binder reads back.
-///
-public sealed class AppSettingsStoreTests : IDisposable
-{
- private readonly TempPaths _paths = new();
-
- public void Dispose() => _paths.Dispose();
-
- [Fact]
- public async Task Saved_settings_are_readable_by_the_configuration_binder()
- {
- var settings = new AppSettings
- {
- Folders = [@"C:\videos", @"D:\more videos"],
- ThumbnailWidth = 640,
- ThumbnailPositionRatio = 0.25,
- MaxIndexingConcurrency = 8,
- MinimumFileSizeInBytes = 2_097_152,
- Theme = ThemeMode.Light,
- Volume = 0.35,
- IsMuted = true,
- };
-
- await CreateStore().SaveAsync(settings, Token);
-
- var (library, appearance, playback) = Reload();
-
- library.Folders.ShouldBe(settings.Folders);
- library.ThumbnailWidth.ShouldBe(640);
- library.ThumbnailPositionRatio.ShouldBe(0.25);
- library.MaxIndexingConcurrency.ShouldBe(8);
- library.MinimumFileSizeInBytes.ShouldBe(2_097_152);
- appearance.Theme.ShouldBe(ThemeMode.Light);
- playback.Volume.ShouldBe(0.35);
- playback.IsMuted.ShouldBeTrue();
- }
-
- [Fact]
- public async Task Removing_a_folder_actually_shortens_the_stored_list()
- {
- var store = CreateStore();
- var settings = Sample with { Folders = [@"C:\a", @"C:\b", @"C:\c"] };
-
- await store.SaveAsync(settings, Token);
- await store.SaveAsync(settings with { Folders = [@"C:\a", @"C:\c"] }, Token);
-
- // Configuration merges arrays by index, so a shorter list is the case most likely
- // to leave a stale entry behind.
- Reload().Library.Folders.ShouldBe([@"C:\a", @"C:\c"]);
- }
-
- [Fact]
- public async Task Keys_the_settings_screen_does_not_know_about_survive_a_save()
- {
- await File.WriteAllTextAsync(
- Path.Combine(_paths.DataDirectory, "settings.json"),
- """{ "Library": { "VideoExtensions": [ ".mp4" ] }, "Experimental": { "Flag": true } }""",
- Token);
-
- await CreateStore().SaveAsync(Sample, Token);
-
- var configuration = Build();
- configuration["Experimental:Flag"].ShouldBe("True");
- configuration["Library:VideoExtensions:0"].ShouldBe(".mp4");
- }
-
- [Theory]
- [InlineData(true)]
- [InlineData(false)]
- public void Only_changes_that_affect_the_contents_of_the_library_ask_for_a_rescan(bool cosmeticOnly)
- {
- var changed = cosmeticOnly
- ? Sample with { ThumbnailWidth = 999, Theme = ThemeMode.Dark }
- : Sample with { Folders = [@"C:\elsewhere"] };
-
- changed.RequiresRescanComparedTo(Sample).ShouldBe(!cosmeticOnly);
- }
-
- private static CancellationToken Token => TestContext.Current.CancellationToken;
-
- private static AppSettings Sample => new()
- {
- Folders = [@"C:\videos"],
- ThumbnailWidth = 480,
- ThumbnailPositionRatio = 0.15,
- MaxIndexingConcurrency = 4,
- MinimumFileSizeInBytes = 65_536,
- Theme = ThemeMode.System,
- Volume = 0.8,
- IsMuted = false,
- };
-
- ///
- /// The store composes from configuration, which these
- /// tests do not exercise — every case here supplies the snapshot it wants to write.
- ///
- private JsonAppSettingsStore CreateStore() => new(
- _paths,
- Monitor(new LibraryOptions()),
- Monitor(new AppearanceOptions()),
- Monitor(new PlaybackOptions()));
-
- private static IOptionsMonitor Monitor(T value)
- {
- var monitor = Substitute.For>();
- monitor.CurrentValue.Returns(value);
- return monitor;
- }
-
- private IConfigurationRoot Build() => new ConfigurationBuilder()
- .AddJsonFile(Path.Combine(_paths.DataDirectory, "settings.json"), optional: false)
- .Build();
-
- private (LibraryOptions Library, AppearanceOptions Appearance, PlaybackOptions Playback) Reload()
- {
- var configuration = Build();
-
- var library = new LibraryOptions();
- configuration.GetSection(LibraryOptions.SectionName).Bind(library);
-
- var appearance = new AppearanceOptions();
- configuration.GetSection(AppearanceOptions.SectionName).Bind(appearance);
-
- var playback = new PlaybackOptions();
- configuration.GetSection(PlaybackOptions.SectionName).Bind(playback);
-
- return (library, appearance, playback);
- }
-
- private sealed class TempPaths : IAppPaths, IDisposable
- {
- public TempPaths()
- {
- DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}");
- ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
- PreviewDirectory = Path.Combine(DataDirectory, "previews");
- DatabaseFile = Path.Combine(DataDirectory, "library.db");
-
- Directory.CreateDirectory(ThumbnailDirectory);
- Directory.CreateDirectory(PreviewDirectory);
- }
-
- public string DataDirectory { get; }
-
- public string ThumbnailDirectory { get; }
-
- public string PreviewDirectory { get; }
-
- public string DatabaseFile { get; }
-
- public void Dispose() => Directory.Delete(DataDirectory, recursive: true);
- }
-}
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using PLib.Application.Library;
+using PLib.Application.Metadata;
+using PLib.Desktop.Services;
+using PLib.Desktop.Settings;
+using PLib.Infrastructure.Storage;
+using Shouldly;
+
+namespace PLib.Tests.Settings;
+
+///
+/// The settings file is not just storage — it is a live configuration source. These tests
+/// close the loop: what the store writes has to be what the options binder reads back.
+///
+public sealed class AppSettingsStoreTests : IDisposable
+{
+ private readonly TempPaths _paths = new();
+
+ public void Dispose() => _paths.Dispose();
+
+ [Fact]
+ public async Task Saved_settings_are_readable_by_the_configuration_binder()
+ {
+ var settings = new AppSettings
+ {
+ Folders = [@"C:\videos", @"D:\more videos"],
+ ThumbnailWidth = 640,
+ ThumbnailPositionRatio = 0.25,
+ MaxIndexingConcurrency = 8,
+ MinimumFileSizeInBytes = 2_097_152,
+ Theme = ThemeMode.Light,
+ Volume = 0.35,
+ IsMuted = true,
+ MetadataSources = [],
+ };
+
+ await CreateStore().SaveAsync(settings, Token);
+
+ var (library, appearance, playback) = Reload();
+
+ library.Folders.ShouldBe(settings.Folders);
+ library.ThumbnailWidth.ShouldBe(640);
+ library.ThumbnailPositionRatio.ShouldBe(0.25);
+ library.MaxIndexingConcurrency.ShouldBe(8);
+ library.MinimumFileSizeInBytes.ShouldBe(2_097_152);
+ appearance.Theme.ShouldBe(ThemeMode.Light);
+ playback.Volume.ShouldBe(0.35);
+ playback.IsMuted.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Removing_a_folder_actually_shortens_the_stored_list()
+ {
+ var store = CreateStore();
+ var settings = Sample with { Folders = [@"C:\a", @"C:\b", @"C:\c"] };
+
+ await store.SaveAsync(settings, Token);
+ await store.SaveAsync(settings with { Folders = [@"C:\a", @"C:\c"] }, Token);
+
+ // Configuration merges arrays by index, so a shorter list is the case most likely
+ // to leave a stale entry behind.
+ Reload().Library.Folders.ShouldBe([@"C:\a", @"C:\c"]);
+ }
+
+ [Fact]
+ public async Task Keys_the_settings_screen_does_not_know_about_survive_a_save()
+ {
+ await File.WriteAllTextAsync(
+ Path.Combine(_paths.DataDirectory, "settings.json"),
+ """{ "Library": { "VideoExtensions": [ ".mp4" ] }, "Experimental": { "Flag": true } }""",
+ Token);
+
+ await CreateStore().SaveAsync(Sample, Token);
+
+ var configuration = Build();
+ configuration["Experimental:Flag"].ShouldBe("True");
+ configuration["Library:VideoExtensions:0"].ShouldBe(".mp4");
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void Only_changes_that_affect_the_contents_of_the_library_ask_for_a_rescan(bool cosmeticOnly)
+ {
+ var changed = cosmeticOnly
+ ? Sample with { ThumbnailWidth = 999, Theme = ThemeMode.Dark }
+ : Sample with { Folders = [@"C:\elsewhere"] };
+
+ changed.RequiresRescanComparedTo(Sample).ShouldBe(!cosmeticOnly);
+ }
+
+ [Fact]
+ public async Task Metadata_sources_survive_the_trip_through_the_file()
+ {
+ var store = CreateStore();
+
+ await store.SaveAsync(
+ Sample with
+ {
+ MetadataSources =
+ [
+ new MetadataSourceOptions { Name = "StashDB", Endpoint = "https://stashdb.org/graphql", ApiKey = "секрет" },
+ new MetadataSourceOptions { Name = "Выключенный", Endpoint = "https://other/graphql", IsEnabled = false },
+ ],
+ },
+ Token);
+
+ var metadata = new MetadataOptions();
+ Build().GetSection(MetadataOptions.SectionName).Bind(metadata);
+
+ metadata.Sources.Select(source => source.Name).ShouldBe(["StashDB", "Выключенный"]);
+ metadata.Sources[0].ApiKey.ShouldBe("секрет");
+ metadata.Sources[0].IsUsable.ShouldBeTrue();
+ metadata.Sources[1].IsUsable.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task Removing_a_source_actually_shortens_the_stored_list()
+ {
+ var store = CreateStore();
+
+ var two = Sample with
+ {
+ MetadataSources =
+ [
+ new MetadataSourceOptions { Name = "Первый", Endpoint = "https://a/graphql" },
+ new MetadataSourceOptions { Name = "Второй", Endpoint = "https://b/graphql" },
+ ],
+ };
+
+ await store.SaveAsync(two, Token);
+ await store.SaveAsync(two with { MetadataSources = [two.MetadataSources[0]] }, Token);
+
+ // Configuration merges arrays by index, so the second entry is exactly what would be
+ // left behind by a save that wrote the list element by element.
+ var metadata = new MetadataOptions();
+ Build().GetSection(MetadataOptions.SectionName).Bind(metadata);
+
+ metadata.Sources.Select(source => source.Name).ShouldBe(["Первый"]);
+ }
+
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ private static AppSettings Sample => new()
+ {
+ Folders = [@"C:\videos"],
+ ThumbnailWidth = 480,
+ ThumbnailPositionRatio = 0.15,
+ MaxIndexingConcurrency = 4,
+ MinimumFileSizeInBytes = 65_536,
+ Theme = ThemeMode.System,
+ Volume = 0.8,
+ IsMuted = false,
+ MetadataSources = [],
+ };
+
+ ///
+ /// The store composes from configuration, which these
+ /// tests do not exercise — every case here supplies the snapshot it wants to write.
+ ///
+ private JsonAppSettingsStore CreateStore() => new(
+ _paths,
+ Monitor(new LibraryOptions()),
+ Monitor(new AppearanceOptions()),
+ Monitor(new PlaybackOptions()),
+ Monitor(new MetadataOptions()));
+
+ private static IOptionsMonitor Monitor(T value)
+ {
+ var monitor = Substitute.For>();
+ monitor.CurrentValue.Returns(value);
+ return monitor;
+ }
+
+ private IConfigurationRoot Build() => new ConfigurationBuilder()
+ .AddJsonFile(Path.Combine(_paths.DataDirectory, "settings.json"), optional: false)
+ .Build();
+
+ private (LibraryOptions Library, AppearanceOptions Appearance, PlaybackOptions Playback) Reload()
+ {
+ var configuration = Build();
+
+ var library = new LibraryOptions();
+ configuration.GetSection(LibraryOptions.SectionName).Bind(library);
+
+ var appearance = new AppearanceOptions();
+ configuration.GetSection(AppearanceOptions.SectionName).Bind(appearance);
+
+ var playback = new PlaybackOptions();
+ configuration.GetSection(PlaybackOptions.SectionName).Bind(playback);
+
+ return (library, appearance, playback);
+ }
+
+ private sealed class TempPaths : IAppPaths, IDisposable
+ {
+ public TempPaths()
+ {
+ DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}");
+ ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
+ PreviewDirectory = Path.Combine(DataDirectory, "previews");
+ DatabaseFile = Path.Combine(DataDirectory, "library.db");
+
+ Directory.CreateDirectory(ThumbnailDirectory);
+ Directory.CreateDirectory(PreviewDirectory);
+ }
+
+ public string DataDirectory { get; }
+
+ public string ThumbnailDirectory { get; }
+
+ public string PreviewDirectory { get; }
+
+ public string DatabaseFile { get; }
+
+ public void Dispose() => Directory.Delete(DataDirectory, recursive: true);
+ }
+}
diff --git a/tests/PLib.Tests/Settings/SettingsViewModelTests.cs b/tests/PLib.Tests/Settings/SettingsViewModelTests.cs
index 8016d35..43211ff 100644
--- a/tests/PLib.Tests/Settings/SettingsViewModelTests.cs
+++ b/tests/PLib.Tests/Settings/SettingsViewModelTests.cs
@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using PLib.Application.Library;
+using PLib.Application.Metadata;
using PLib.Desktop.Services;
using PLib.Desktop.Settings;
using PLib.Desktop.ViewModels;
@@ -108,7 +109,11 @@ public sealed class SettingsViewModelTests
private SettingsViewModel Create(LibraryOptions options, PlaybackOptions? playback = null)
{
_store.Current.Returns(
- AppSettings.From(options, new AppearanceOptions(), playback ?? new PlaybackOptions()));
+ AppSettings.From(
+ options,
+ new AppearanceOptions(),
+ playback ?? new PlaybackOptions(),
+ new MetadataOptions()));
return new SettingsViewModel(
Substitute.For(),