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