From 70611c2051ae180f0352de71e5f5de2acde540f0 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 10 Aug 2026 11:29:58 +0300 Subject: [PATCH] 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. --- README.md | 13 +- .../Abstractions/IVideoRepository.cs | 67 ++- .../Library/LibraryService.cs | 41 +- .../Metadata/MetadataScanEvent.cs | 36 +- src/PLib.Desktop/Themes/LibraryStyles.axaml | 13 + .../ViewModels/MetadataScanResultViewModel.cs | 8 + .../ViewModels/MetadataScanScopeOption.cs | 30 + .../ViewModels/MetadataScanViewModel.cs | 12 +- src/PLib.Desktop/Views/MetadataScanView.axaml | 93 ++- .../Videos/AppliedMetadataSource.cs | 38 ++ src/PLib.Domain/Videos/VideoItem.cs | 553 ++++++++++-------- .../Persistence/EfVideoRepository.cs | 99 ++-- ...20260810080601_MetadataApplied.Designer.cs | 176 ++++++ .../20260810080601_MetadataApplied.cs | 51 ++ ...0082839_AppliedMetadataSources.Designer.cs | 202 +++++++ .../20260810082839_AppliedMetadataSources.cs | 98 ++++ .../LibraryDbContextModelSnapshot.cs | 33 ++ .../Persistence/VideoItemConfiguration.cs | 171 +++--- .../Library/InMemoryVideoRepository.cs | 120 ++-- tests/PLib.Tests/Library/MetadataScanTests.cs | 46 +- 20 files changed, 1387 insertions(+), 513 deletions(-) create mode 100644 src/PLib.Desktop/ViewModels/MetadataScanScopeOption.cs create mode 100644 src/PLib.Domain/Videos/AppliedMetadataSource.cs create mode 100644 src/PLib.Infrastructure/Persistence/Migrations/20260810080601_MetadataApplied.Designer.cs create mode 100644 src/PLib.Infrastructure/Persistence/Migrations/20260810080601_MetadataApplied.cs create mode 100644 src/PLib.Infrastructure/Persistence/Migrations/20260810082839_AppliedMetadataSources.Designer.cs create mode 100644 src/PLib.Infrastructure/Persistence/Migrations/20260810082839_AppliedMetadataSources.cs diff --git a/README.md b/README.md index f32d24f..da405ff 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия. - Вкладка «Метаданные» — тот же поиск сразу по всей библиотеке, с прогрессом, остановкой - и списком найденного. Каждый кандидат показывается со своей обложкой. По желанию - однозначные совпадения применяются на месте. + и списком найденного. Рядом с кандидатами показывается наш собственный кадр, и каждая + картинка подписана, чья она. По желанию однозначные совпадения применяются на месте. - Светлая, тёмная и системная темы; выбор запоминается. - Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео, перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход — @@ -177,6 +177,15 @@ dotnet test видео, и тысяча одинаковых строк была бы всей страницей. «Применять однозначные сразу» по умолчанию выключено, а два кандидата не применяются никогда — расхождение источников это ровно тот случай, ради которого страницу и смотрят. + На странице подписана каждая картинка: наш кадр в акцентной рамке с подписью + «В библиотеке», кандидатский — в обычной, с именем источника. Решение принимается + глазами, и два неподписанных кадра рядом делают его невозможным даже сформулировать. + **Признак «уже размечено» — отдельная отметка, а не наличие описания.** Сначала прогон + пропускал видео с непустым `Description`, и это ломалось на источниках, у которых есть + название, актёры и теги, но нет синопсиса: такие видео возвращались на каждом запуске. + Теперь применение ставит `MetadataAppliedAt` и имя источника, а миграция проставляет + отметку тем, у кого описание уже есть, — иначе первый же запуск после обновления предложил + бы заново всё, что уже было сделано. - **Картинки скачиваются один раз и по размеру.** `images` в stash-box есть у сцены, у актёра и у студии; у тега такого поля нет вовсе, поэтому там карточка показывает первую букву — это норма, а не отсутствие данных. Обложка сцены нужна там, где выбирают из diff --git a/src/PLib.Application/Abstractions/IVideoRepository.cs b/src/PLib.Application/Abstractions/IVideoRepository.cs index 9ad75b5..07d17f4 100644 --- a/src/PLib.Application/Abstractions/IVideoRepository.cs +++ b/src/PLib.Application/Abstractions/IVideoRepository.cs @@ -1,30 +1,37 @@ -using PLib.Domain.Videos; - -namespace PLib.Application.Abstractions; - -/// -/// Persistence boundary for the library. The application layer only ever talks to this -/// interface, which keeps EF Core (and SQLite) an implementation detail of the outer ring. -/// -public interface IVideoRepository -{ - Task> GetAllAsync(CancellationToken cancellationToken = default); - - /// - /// Every video with its labels loaded. Kept apart from because - /// the scan reconciles thousands of rows against disk and has no use for the join. - /// - Task> GetAllWithLabelsAsync(CancellationToken cancellationToken = default); - - Task FindByPathAsync(string fullPath, CancellationToken cancellationToken = default); - - /// Loads one video together with the labels attached to it. - Task FindWithLabelsAsync(Guid id, CancellationToken cancellationToken = default); - - Task AddAsync(VideoItem item, CancellationToken cancellationToken = default); - - Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default); - - /// Flushes every pending change made to tracked entities. - Task SaveChangesAsync(CancellationToken cancellationToken = default); -} +using PLib.Domain.Videos; + +namespace PLib.Application.Abstractions; + +/// +/// Persistence boundary for the library. The application layer only ever talks to this +/// interface, which keeps EF Core (and SQLite) an implementation detail of the outer ring. +/// +public interface IVideoRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + + /// + /// Every video with its labels loaded. Kept apart from because + /// the scan reconciles thousands of rows against disk and has no use for the join. + /// + Task> GetAllWithLabelsAsync(CancellationToken cancellationToken = default); + + /// + /// Every video with the record of which metadata sources have been applied to it. A + /// separate method for the same reason as the labels one: the scan walks these rows + /// against disk thousands at a time and has no use for either join. + /// + Task> GetAllWithMetadataSourcesAsync(CancellationToken cancellationToken = default); + + Task FindByPathAsync(string fullPath, CancellationToken cancellationToken = default); + + /// Loads one video together with the labels attached to it. + Task FindWithLabelsAsync(Guid id, CancellationToken cancellationToken = default); + + Task AddAsync(VideoItem item, CancellationToken cancellationToken = default); + + Task RemoveAsync(VideoItem item, CancellationToken cancellationToken = default); + + /// Flushes every pending change made to tracked entities. + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/PLib.Application/Library/LibraryService.cs b/src/PLib.Application/Library/LibraryService.cs index a5ab03d..4bb18d4 100644 --- a/src/PLib.Application/Library/LibraryService.cs +++ b/src/PLib.Application/Library/LibraryService.cs @@ -214,6 +214,31 @@ public sealed class LibraryService( public Task FetchImageAsync(string imageUrl, CancellationToken cancellationToken = default) => remoteImages.GetOrCreateAsync(imageUrl, cancellationToken); + /// Whether a run of this scope has anything left to ask about this video. + private static bool Covers( + MetadataScanScope scope, + VideoItem video, + IReadOnlyList sources) => scope switch + { + MetadataScanScope.Unmatched => !video.HasMetadata, + MetadataScanScope.MissingSources => sources.Any(source => !video.HasMetadataFrom(source.Name)), + _ => true, + }; + + /// + /// Which sources to ask about this video. + /// + /// + /// Narrowed only for , which exists precisely + /// because a source that has already had its say would spend a request to repeat itself. + /// + private static IEnumerable SourcesFor( + MetadataScanScope scope, + VideoItem video, + IReadOnlyList sources) => scope == MetadataScanScope.MissingSources + ? sources.Where(source => !video.HasMetadataFrom(source.Name)) + : sources; + public async IAsyncEnumerable ScanMetadataAsync( MetadataScanRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default) @@ -225,9 +250,11 @@ public sealed class LibraryService( var sources = metadataOptions.CurrentValue.Sources.Where(source => source.IsUsable).ToList(); var pause = TimeSpan.FromMilliseconds(metadataOptions.CurrentValue.RequestDelayMilliseconds); - var candidates = (await repository.GetAllAsync(cancellationToken)) + // Asked of the record of having been matched, not of the description: a source with + // no synopsis leaves that empty, and such videos came back on every run. + var candidates = (await repository.GetAllWithMetadataSourcesAsync(cancellationToken)) .Where(video => video.PerceptualHash is not null) - .Where(video => !request.OnlyWithoutDescription || video.Description is null) + .Where(video => Covers(request.Scope, video, sources)) .OrderBy(video => video.Title, StringComparer.CurrentCultureIgnoreCase) .ToList(); @@ -244,7 +271,7 @@ public sealed class LibraryService( var found = new List(); - foreach (var source in sources.Where(source => !abandoned.Contains(source))) + foreach (var source in SourcesFor(request.Scope, video, sources).Where(source => !abandoned.Contains(source))) { if (pause > TimeSpan.Zero) { @@ -295,7 +322,12 @@ public sealed class LibraryService( applied++; } - yield return new MetadataScanEvent.Matched(video.Id, video.Title, found, unambiguous); + yield return new MetadataScanEvent.Matched( + video.Id, + video.Title, + video.ThumbnailPath, + found, + unambiguous); } yield return new MetadataScanEvent.Progress(processed, candidates.Count); @@ -326,6 +358,7 @@ public sealed class LibraryService( } video.Describe(match.Description); + video.MarkMetadataApplied(match.SourceName); await AttachAllAsync(video, match.Tags, LabelKind.Tag, cancellationToken); await AttachAllAsync(video, match.Performers, LabelKind.Performer, cancellationToken); diff --git a/src/PLib.Application/Metadata/MetadataScanEvent.cs b/src/PLib.Application/Metadata/MetadataScanEvent.cs index 3005434..4cc2842 100644 --- a/src/PLib.Application/Metadata/MetadataScanEvent.cs +++ b/src/PLib.Application/Metadata/MetadataScanEvent.cs @@ -1,16 +1,36 @@ namespace PLib.Application.Metadata; +/// Which videos a library-wide run should cover. +public enum MetadataScanScope +{ + /// Only videos nothing has been applied to yet. + Unmatched, + + /// + /// Videos that some configured source has not yet been asked about — and only those + /// sources are asked. + /// + /// + /// What a newly added source is for: everything already carries PornDB's answers, and + /// the question now is what StashDB adds. Asking the source that already answered would + /// spend a request to be told what is already on the video. + /// + MissingSources, + + /// Every fingerprinted video, every source, regardless of what was applied. + Everything, +} + /// What a library-wide metadata run should cover and how much it may decide alone. -/// -/// Skip videos that already carry a description. A second run over a large library is usually -/// meant to fill the gaps, not to ask again about everything that already worked. -/// +/// Which videos to ask about, and which sources to ask. /// /// Apply a result without asking when exactly one source returned exactly one candidate. /// Off by default: a fingerprint match is a proposal, and several proposals disagreeing is /// precisely the case a human has to look at. /// -public sealed record MetadataScanRequest(bool OnlyWithoutDescription = true, bool ApplyUnambiguous = false); +public sealed record MetadataScanRequest( + MetadataScanScope Scope = MetadataScanScope.Unmatched, + bool ApplyUnambiguous = false); /// Something that happened during a library-wide metadata run. public abstract record MetadataScanEvent @@ -23,10 +43,16 @@ public abstract record MetadataScanEvent public sealed record Progress(int Processed, int Total) : MetadataScanEvent; /// A video the sources had something to say about. + /// + /// The library's own poster frame for this video. Carried so the page can put it beside + /// the candidates: a fingerprint match is judged by eye, and there is nothing to judge + /// against if only the proposals have pictures. + /// /// True when it was unambiguous and the run wrote it straight away. public sealed record Matched( Guid VideoId, string VideoTitle, + string? VideoThumbnailPath, IReadOnlyList Matches, bool Applied) : MetadataScanEvent; diff --git a/src/PLib.Desktop/Themes/LibraryStyles.axaml b/src/PLib.Desktop/Themes/LibraryStyles.axaml index e6993f4..032e960 100644 --- a/src/PLib.Desktop/Themes/LibraryStyles.axaml +++ b/src/PLib.Desktop/Themes/LibraryStyles.axaml @@ -297,6 +297,19 @@ + + +