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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user