Refactor metadata handling in PLib video library manager to improve video matching accuracy. Introduce new properties for tracking when metadata was applied and from which source. Update LibraryService and related components to utilize these properties, ensuring videos without synopses are correctly identified as matched. Enhance UI to display the library's own thumbnail alongside candidate matches, improving user decision-making. Revise README.md to reflect these changes and clarify metadata application processes.
This commit is contained in:
@@ -1,58 +1,62 @@
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Tests.Library;
|
||||
|
||||
/// <summary>
|
||||
/// A hand-written double rather than a mock: the scan logic is all about what ends up in the
|
||||
/// repository, so the tests read better when they can just look at the resulting list.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryVideoRepository : IVideoRepository
|
||||
{
|
||||
private readonly Dictionary<string, VideoItem> _items = new(LibraryPathComparer.Instance);
|
||||
|
||||
public int SaveCount { get; private set; }
|
||||
|
||||
public IReadOnlyCollection<VideoItem> Items => _items.Values;
|
||||
|
||||
public void Seed(params VideoItem[] items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
_items[item.FullPath] = item;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<VideoItem>> GetAllAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<VideoItem>>([.. _items.Values]);
|
||||
|
||||
// Labels are held on the entity itself here, so this is the same list.
|
||||
public Task<IReadOnlyList<VideoItem>> GetAllWithLabelsAsync(CancellationToken cancellationToken = default) =>
|
||||
GetAllAsync(cancellationToken);
|
||||
|
||||
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_items.GetValueOrDefault(fullPath));
|
||||
|
||||
// Labels are held on the entity itself here, so there is nothing extra to load.
|
||||
public Task<VideoItem?> FindWithLabelsAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_items.Values.FirstOrDefault(item => item.Id == id));
|
||||
|
||||
public Task AddAsync(VideoItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_items[item.FullPath] = item;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_items.Remove(item.FullPath);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
SaveCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
using PLib.Application.Abstractions;
|
||||
using PLib.Application.Library;
|
||||
using PLib.Domain.Videos;
|
||||
|
||||
namespace PLib.Tests.Library;
|
||||
|
||||
/// <summary>
|
||||
/// A hand-written double rather than a mock: the scan logic is all about what ends up in the
|
||||
/// repository, so the tests read better when they can just look at the resulting list.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryVideoRepository : IVideoRepository
|
||||
{
|
||||
private readonly Dictionary<string, VideoItem> _items = new(LibraryPathComparer.Instance);
|
||||
|
||||
public int SaveCount { get; private set; }
|
||||
|
||||
public IReadOnlyCollection<VideoItem> Items => _items.Values;
|
||||
|
||||
public void Seed(params VideoItem[] items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
_items[item.FullPath] = item;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<VideoItem>> GetAllAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<VideoItem>>([.. _items.Values]);
|
||||
|
||||
// Labels are held on the entity itself here, so this is the same list.
|
||||
public Task<IReadOnlyList<VideoItem>> GetAllWithLabelsAsync(CancellationToken cancellationToken = default) =>
|
||||
GetAllAsync(cancellationToken);
|
||||
|
||||
// Applied sources live on the entity here, so this is the same list again.
|
||||
public Task<IReadOnlyList<VideoItem>> GetAllWithMetadataSourcesAsync(CancellationToken cancellationToken = default) =>
|
||||
GetAllAsync(cancellationToken);
|
||||
|
||||
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_items.GetValueOrDefault(fullPath));
|
||||
|
||||
// Labels are held on the entity itself here, so there is nothing extra to load.
|
||||
public Task<VideoItem?> FindWithLabelsAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_items.Values.FirstOrDefault(item => item.Id == id));
|
||||
|
||||
public Task AddAsync(VideoItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_items[item.FullPath] = item;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_items.Remove(item.FullPath);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
SaveCount++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,21 +39,57 @@ public sealed class MetadataScanTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Videos_that_already_have_a_description_are_skipped_by_default()
|
||||
public async Task Videos_a_match_was_already_applied_to_are_skipped_by_default()
|
||||
{
|
||||
var described = Video("описанное", hash: 1);
|
||||
described.Describe("уже есть");
|
||||
_videos.Seed(described, Video("голое", hash: 2));
|
||||
var done = Video("размеченное", hash: 1);
|
||||
done.MarkMetadataApplied("StashDB");
|
||||
_videos.Seed(done, Video("голое", hash: 2));
|
||||
|
||||
Answer(_source, []);
|
||||
|
||||
(await CollectAsync()).OfType<MetadataScanEvent.Completed>().Single().Processed.ShouldBe(1);
|
||||
|
||||
// Asking again for everything is what the switch is for.
|
||||
var all = await CollectAsync(new MetadataScanRequest(OnlyWithoutDescription: false));
|
||||
var all = await CollectAsync(new MetadataScanRequest(OnlyWithoutMetadata: false));
|
||||
all.OfType<MetadataScanEvent.Completed>().Single().Processed.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_match_with_no_synopsis_still_counts_as_applied()
|
||||
{
|
||||
var video = Video("видео", hash: 1);
|
||||
_videos.Seed(video);
|
||||
|
||||
// stash-box scenes often carry a title, performers and tags but no details at all.
|
||||
Answer(_source, [Match("Название") with { Description = null }]);
|
||||
|
||||
await CollectAsync(new MetadataScanRequest(ApplyUnambiguous: true));
|
||||
|
||||
video.Description.ShouldBeNull();
|
||||
video.MetadataAppliedAt.ShouldNotBeNull();
|
||||
video.MetadataSource.ShouldBe("StashDB");
|
||||
|
||||
// Judging "already done" by the description sent these back on every single run.
|
||||
var again = await CollectAsync(new MetadataScanRequest(ApplyUnambiguous: true));
|
||||
again.OfType<MetadataScanEvent.Completed>().Single().Processed.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_result_carries_our_own_poster_frame_for_the_user_to_compare_against()
|
||||
{
|
||||
var video = Video("видео", hash: 1);
|
||||
video.AttachThumbnail(@"C:\cache\ours.jpg");
|
||||
_videos.Seed(video);
|
||||
|
||||
Answer(_source, [Match("Название")]);
|
||||
|
||||
var matched = (await CollectAsync()).OfType<MetadataScanEvent.Matched>().Single();
|
||||
|
||||
// Deciding whether a proposal is the same video is done by eye, and there is nothing
|
||||
// to judge against if only the proposals have pictures.
|
||||
matched.VideoThumbnailPath.ShouldBe(@"C:\cache\ours.jpg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_single_candidate_is_written_only_when_the_run_was_told_it_may()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user