Implement metadata management features in PLib video library manager. Introduce functionality to query and apply metadata from external sources based on video fingerprints. Update ILibraryService and LibraryService to support metadata lookup and application, enhancing video item descriptions and labels. Revise UI components in VideoPlayerView and SettingsView to facilitate user interaction with metadata sources. Update README.md to document new metadata features and usage instructions.
This commit is contained in:
@@ -91,6 +91,8 @@ public sealed class DuplicateDetectionTests
|
||||
Substitute.For<IThumbnailGenerator>(),
|
||||
Substitute.For<IAnimatedPreviewGenerator>(),
|
||||
Substitute.For<IVideoPerceptualHasher>(),
|
||||
Substitute.For<IMetadataProvider>(),
|
||||
Options.Create(new LibraryOptions()),
|
||||
MetadataMonitor.Empty,
|
||||
NullLogger<LibraryService>.Instance);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ public sealed class LabelTests
|
||||
Substitute.For<IThumbnailGenerator>(),
|
||||
Substitute.For<IAnimatedPreviewGenerator>(),
|
||||
Substitute.For<IVideoPerceptualHasher>(),
|
||||
Substitute.For<IMetadataProvider>(),
|
||||
Options.Create(new LibraryOptions()),
|
||||
MetadataMonitor.Empty,
|
||||
NullLogger<LibraryService>.Instance);
|
||||
}
|
||||
|
||||
@@ -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<IVideoFileScanner>();
|
||||
private readonly IMediaProbe _probe = Substitute.For<IMediaProbe>();
|
||||
private readonly IThumbnailGenerator _thumbnails = Substitute.For<IThumbnailGenerator>();
|
||||
private readonly IAnimatedPreviewGenerator _previews = Substitute.For<IAnimatedPreviewGenerator>();
|
||||
private readonly IVideoPerceptualHasher _hasher = Substitute.For<IVideoPerceptualHasher>();
|
||||
private readonly IMetadataProvider _metadata = Substitute.For<IMetadataProvider>();
|
||||
|
||||
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<MetadataSourceOptions>(),
|
||||
Arg.Any<ulong>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<ulong>(), Arg.Any<CancellationToken>())
|
||||
.Returns([Match("Сцена", good.Name)]);
|
||||
_metadata.FindByPerceptualHashAsync(bad, Arg.Any<ulong>(), Arg.Any<CancellationToken>())
|
||||
.Returns<IReadOnlyList<VideoMetadataMatch>>(_ => 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<MetadataSourceOptions>(),
|
||||
Arg.Any<ulong>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<string>(), Arg.Any<CancellationToken>())
|
||||
.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<LibraryService>.Instance);
|
||||
|
||||
private static async Task<List<LibraryScanEvent>> CollectAsync(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using PLib.Application.Metadata;
|
||||
|
||||
namespace PLib.Tests.Library;
|
||||
|
||||
/// <summary>
|
||||
/// A fixed <see cref="IOptionsMonitor{T}"/> over metadata sources.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The service reads <c>CurrentValue</c> 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.
|
||||
/// </remarks>
|
||||
internal sealed class MetadataMonitor(MetadataOptions value) : IOptionsMonitor<MetadataOptions>
|
||||
{
|
||||
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<MetadataOptions, string?> listener) => null;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The store composes <see cref="AppSettings.Current"/> from configuration, which these
|
||||
/// tests do not exercise — every case here supplies the snapshot it wants to write.
|
||||
/// </summary>
|
||||
private JsonAppSettingsStore CreateStore() => new(
|
||||
_paths,
|
||||
Monitor(new LibraryOptions()),
|
||||
Monitor(new AppearanceOptions()),
|
||||
Monitor(new PlaybackOptions()));
|
||||
|
||||
private static IOptionsMonitor<T> Monitor<T>(T value)
|
||||
{
|
||||
var monitor = Substitute.For<IOptionsMonitor<T>>();
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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 = [],
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The store composes <see cref="AppSettings.Current"/> from configuration, which these
|
||||
/// tests do not exercise — every case here supplies the snapshot it wants to write.
|
||||
/// </summary>
|
||||
private JsonAppSettingsStore CreateStore() => new(
|
||||
_paths,
|
||||
Monitor(new LibraryOptions()),
|
||||
Monitor(new AppearanceOptions()),
|
||||
Monitor(new PlaybackOptions()),
|
||||
Monitor(new MetadataOptions()));
|
||||
|
||||
private static IOptionsMonitor<T> Monitor<T>(T value)
|
||||
{
|
||||
var monitor = Substitute.For<IOptionsMonitor<T>>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IServiceScopeFactory>(),
|
||||
|
||||
Reference in New Issue
Block a user