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 @@ + + +