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:
@@ -27,8 +27,8 @@
|
|||||||
stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается
|
stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается
|
||||||
списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия.
|
списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия.
|
||||||
- Вкладка «Метаданные» — тот же поиск сразу по всей библиотеке, с прогрессом, остановкой
|
- Вкладка «Метаданные» — тот же поиск сразу по всей библиотеке, с прогрессом, остановкой
|
||||||
и списком найденного. Каждый кандидат показывается со своей обложкой. По желанию
|
и списком найденного. Рядом с кандидатами показывается наш собственный кадр, и каждая
|
||||||
однозначные совпадения применяются на месте.
|
картинка подписана, чья она. По желанию однозначные совпадения применяются на месте.
|
||||||
- Светлая, тёмная и системная темы; выбор запоминается.
|
- Светлая, тёмная и системная темы; выбор запоминается.
|
||||||
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
|
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
|
||||||
перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход —
|
перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход —
|
||||||
@@ -177,6 +177,15 @@ dotnet test
|
|||||||
видео, и тысяча одинаковых строк была бы всей страницей.
|
видео, и тысяча одинаковых строк была бы всей страницей.
|
||||||
«Применять однозначные сразу» по умолчанию выключено, а два кандидата не применяются никогда
|
«Применять однозначные сразу» по умолчанию выключено, а два кандидата не применяются никогда
|
||||||
— расхождение источников это ровно тот случай, ради которого страницу и смотрят.
|
— расхождение источников это ровно тот случай, ради которого страницу и смотрят.
|
||||||
|
На странице подписана каждая картинка: наш кадр в акцентной рамке с подписью
|
||||||
|
«В библиотеке», кандидатский — в обычной, с именем источника. Решение принимается
|
||||||
|
глазами, и два неподписанных кадра рядом делают его невозможным даже сформулировать.
|
||||||
|
**Признак «уже размечено» — отдельная отметка, а не наличие описания.** Сначала прогон
|
||||||
|
пропускал видео с непустым `Description`, и это ломалось на источниках, у которых есть
|
||||||
|
название, актёры и теги, но нет синопсиса: такие видео возвращались на каждом запуске.
|
||||||
|
Теперь применение ставит `MetadataAppliedAt` и имя источника, а миграция проставляет
|
||||||
|
отметку тем, у кого описание уже есть, — иначе первый же запуск после обновления предложил
|
||||||
|
бы заново всё, что уже было сделано.
|
||||||
- **Картинки скачиваются один раз и по размеру.** `images` в stash-box есть у сцены,
|
- **Картинки скачиваются один раз и по размеру.** `images` в stash-box есть у сцены,
|
||||||
у актёра и у студии; у тега такого поля нет вовсе, поэтому там карточка показывает первую
|
у актёра и у студии; у тега такого поля нет вовсе, поэтому там карточка показывает первую
|
||||||
букву — это норма, а не отсутствие данных. Обложка сцены нужна там, где выбирают из
|
букву — это норма, а не отсутствие данных. Обложка сцены нужна там, где выбирают из
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ public interface IVideoRepository
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyList<VideoItem>> GetAllWithLabelsAsync(CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<VideoItem>> GetAllWithLabelsAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<VideoItem>> GetAllWithMetadataSourcesAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default);
|
Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>Loads one video together with the labels attached to it.</summary>
|
/// <summary>Loads one video together with the labels attached to it.</summary>
|
||||||
|
|||||||
@@ -214,6 +214,31 @@ public sealed class LibraryService(
|
|||||||
public Task<RemoteImage> FetchImageAsync(string imageUrl, CancellationToken cancellationToken = default) =>
|
public Task<RemoteImage> FetchImageAsync(string imageUrl, CancellationToken cancellationToken = default) =>
|
||||||
remoteImages.GetOrCreateAsync(imageUrl, cancellationToken);
|
remoteImages.GetOrCreateAsync(imageUrl, cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Whether a run of this scope has anything left to ask about this video.</summary>
|
||||||
|
private static bool Covers(
|
||||||
|
MetadataScanScope scope,
|
||||||
|
VideoItem video,
|
||||||
|
IReadOnlyList<MetadataSourceOptions> sources) => scope switch
|
||||||
|
{
|
||||||
|
MetadataScanScope.Unmatched => !video.HasMetadata,
|
||||||
|
MetadataScanScope.MissingSources => sources.Any(source => !video.HasMetadataFrom(source.Name)),
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which sources to ask about this video.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Narrowed only for <see cref="MetadataScanScope.MissingSources"/>, which exists precisely
|
||||||
|
/// because a source that has already had its say would spend a request to repeat itself.
|
||||||
|
/// </remarks>
|
||||||
|
private static IEnumerable<MetadataSourceOptions> SourcesFor(
|
||||||
|
MetadataScanScope scope,
|
||||||
|
VideoItem video,
|
||||||
|
IReadOnlyList<MetadataSourceOptions> sources) => scope == MetadataScanScope.MissingSources
|
||||||
|
? sources.Where(source => !video.HasMetadataFrom(source.Name))
|
||||||
|
: sources;
|
||||||
|
|
||||||
public async IAsyncEnumerable<MetadataScanEvent> ScanMetadataAsync(
|
public async IAsyncEnumerable<MetadataScanEvent> ScanMetadataAsync(
|
||||||
MetadataScanRequest request,
|
MetadataScanRequest request,
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
@@ -225,9 +250,11 @@ public sealed class LibraryService(
|
|||||||
var sources = metadataOptions.CurrentValue.Sources.Where(source => source.IsUsable).ToList();
|
var sources = metadataOptions.CurrentValue.Sources.Where(source => source.IsUsable).ToList();
|
||||||
var pause = TimeSpan.FromMilliseconds(metadataOptions.CurrentValue.RequestDelayMilliseconds);
|
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 => 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)
|
.OrderBy(video => video.Title, StringComparer.CurrentCultureIgnoreCase)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -244,7 +271,7 @@ public sealed class LibraryService(
|
|||||||
|
|
||||||
var found = new List<VideoMetadataMatch>();
|
var found = new List<VideoMetadataMatch>();
|
||||||
|
|
||||||
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)
|
if (pause > TimeSpan.Zero)
|
||||||
{
|
{
|
||||||
@@ -295,7 +322,12 @@ public sealed class LibraryService(
|
|||||||
applied++;
|
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);
|
yield return new MetadataScanEvent.Progress(processed, candidates.Count);
|
||||||
@@ -326,6 +358,7 @@ public sealed class LibraryService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
video.Describe(match.Description);
|
video.Describe(match.Description);
|
||||||
|
video.MarkMetadataApplied(match.SourceName);
|
||||||
|
|
||||||
await AttachAllAsync(video, match.Tags, LabelKind.Tag, cancellationToken);
|
await AttachAllAsync(video, match.Tags, LabelKind.Tag, cancellationToken);
|
||||||
await AttachAllAsync(video, match.Performers, LabelKind.Performer, cancellationToken);
|
await AttachAllAsync(video, match.Performers, LabelKind.Performer, cancellationToken);
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
namespace PLib.Application.Metadata;
|
namespace PLib.Application.Metadata;
|
||||||
|
|
||||||
|
/// <summary>Which videos a library-wide run should cover.</summary>
|
||||||
|
public enum MetadataScanScope
|
||||||
|
{
|
||||||
|
/// <summary>Only videos nothing has been applied to yet.</summary>
|
||||||
|
Unmatched,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Videos that some configured source has not yet been asked about — and only those
|
||||||
|
/// sources are asked.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
MissingSources,
|
||||||
|
|
||||||
|
/// <summary>Every fingerprinted video, every source, regardless of what was applied.</summary>
|
||||||
|
Everything,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>What a library-wide metadata run should cover and how much it may decide alone.</summary>
|
/// <summary>What a library-wide metadata run should cover and how much it may decide alone.</summary>
|
||||||
/// <param name="OnlyWithoutDescription">
|
/// <param name="Scope">Which videos to ask about, and which sources to ask.</param>
|
||||||
/// 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.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="ApplyUnambiguous">
|
/// <param name="ApplyUnambiguous">
|
||||||
/// Apply a result without asking when exactly one source returned exactly one candidate.
|
/// 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
|
/// Off by default: a fingerprint match is a proposal, and several proposals disagreeing is
|
||||||
/// precisely the case a human has to look at.
|
/// precisely the case a human has to look at.
|
||||||
/// </param>
|
/// </param>
|
||||||
public sealed record MetadataScanRequest(bool OnlyWithoutDescription = true, bool ApplyUnambiguous = false);
|
public sealed record MetadataScanRequest(
|
||||||
|
MetadataScanScope Scope = MetadataScanScope.Unmatched,
|
||||||
|
bool ApplyUnambiguous = false);
|
||||||
|
|
||||||
/// <summary>Something that happened during a library-wide metadata run.</summary>
|
/// <summary>Something that happened during a library-wide metadata run.</summary>
|
||||||
public abstract record MetadataScanEvent
|
public abstract record MetadataScanEvent
|
||||||
@@ -23,10 +43,16 @@ public abstract record MetadataScanEvent
|
|||||||
public sealed record Progress(int Processed, int Total) : MetadataScanEvent;
|
public sealed record Progress(int Processed, int Total) : MetadataScanEvent;
|
||||||
|
|
||||||
/// <summary>A video the sources had something to say about.</summary>
|
/// <summary>A video the sources had something to say about.</summary>
|
||||||
|
/// <param name="VideoThumbnailPath">
|
||||||
|
/// 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.
|
||||||
|
/// </param>
|
||||||
/// <param name="Applied">True when it was unambiguous and the run wrote it straight away.</param>
|
/// <param name="Applied">True when it was unambiguous and the run wrote it straight away.</param>
|
||||||
public sealed record Matched(
|
public sealed record Matched(
|
||||||
Guid VideoId,
|
Guid VideoId,
|
||||||
string VideoTitle,
|
string VideoTitle,
|
||||||
|
string? VideoThumbnailPath,
|
||||||
IReadOnlyList<VideoMetadataMatch> Matches,
|
IReadOnlyList<VideoMetadataMatch> Matches,
|
||||||
bool Applied) : MetadataScanEvent;
|
bool Applied) : MetadataScanEvent;
|
||||||
|
|
||||||
|
|||||||
@@ -297,6 +297,19 @@
|
|||||||
<Setter Property="RenderTransform" Value="none" />
|
<Setter Property="RenderTransform" Value="none" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Says where a picture came from. Every frame on the metadata page carries one: the page
|
||||||
|
exists to decide whether a proposal is the same video as the file, and two unlabelled
|
||||||
|
frames side by side make that decision impossible to state, let alone make.
|
||||||
|
-->
|
||||||
|
<Style Selector="TextBlock.imageCaption">
|
||||||
|
<Setter Property="FontSize" Value="10.5" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="MaxWidth" Value="128" />
|
||||||
|
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextTertiaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style Selector="TextBlock.taskTitle">
|
<Style Selector="TextBlock.taskTitle">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||||
<Setter Property="FontSize" Value="12.5" />
|
<Setter Property="FontSize" Value="12.5" />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed partial class MetadataScanResultViewModel : ReactiveObject
|
|||||||
|
|
||||||
VideoId = matched.VideoId;
|
VideoId = matched.VideoId;
|
||||||
VideoTitle = matched.VideoTitle;
|
VideoTitle = matched.VideoTitle;
|
||||||
|
VideoThumbnailPath = matched.VideoThumbnailPath;
|
||||||
|
|
||||||
foreach (var match in matched.Matches)
|
foreach (var match in matched.Matches)
|
||||||
{
|
{
|
||||||
@@ -31,6 +32,13 @@ public sealed partial class MetadataScanResultViewModel : ReactiveObject
|
|||||||
|
|
||||||
public string VideoTitle { get; }
|
public string VideoTitle { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Our own poster frame for the file, to compare the candidates against. Every picture on
|
||||||
|
/// this page is captioned with where it came from: the whole exercise is deciding whether
|
||||||
|
/// the proposal is the same video, and that is impossible if the two can be mixed up.
|
||||||
|
/// </summary>
|
||||||
|
public string? VideoThumbnailPath { get; }
|
||||||
|
|
||||||
public ObservableCollection<MetadataMatchViewModel> Matches { get; } = [];
|
public ObservableCollection<MetadataMatchViewModel> Matches { get; } = [];
|
||||||
|
|
||||||
public bool IsAmbiguous => Matches.Count > 1;
|
public bool IsAmbiguous => Matches.Count > 1;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using PLib.Application.Metadata;
|
||||||
|
|
||||||
|
namespace PLib.Desktop.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>One choice of what a library-wide run should cover, with what it means.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The hint is shown under the picker rather than hidden in a tooltip. The three options
|
||||||
|
/// differ by which requests they will send to somebody else's servers, and that difference is
|
||||||
|
/// not something a two-word label can carry.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record MetadataScanScopeOption(MetadataScanScope Scope, string Label, string Hint)
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<MetadataScanScopeOption> All { get; } =
|
||||||
|
[
|
||||||
|
new(
|
||||||
|
MetadataScanScope.Unmatched,
|
||||||
|
"Только неразмеченные",
|
||||||
|
"Видео, к которым ещё не применялось ни одно совпадение."),
|
||||||
|
|
||||||
|
new(
|
||||||
|
MetadataScanScope.MissingSources,
|
||||||
|
"Источники, которых ещё не спрашивали",
|
||||||
|
"Для каждого видео опрашиваются только те источники, чьи данные к нему ещё не применяли. То, что нужно после добавления нового источника."),
|
||||||
|
|
||||||
|
new(
|
||||||
|
MetadataScanScope.Everything,
|
||||||
|
"Всё заново",
|
||||||
|
"Все видео с отпечатком и все источники, включая уже применённые."),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
|
|||||||
_refreshLibrary = refreshLibrary;
|
_refreshLibrary = refreshLibrary;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
|
SelectedScope = MetadataScanScopeOption.All[0];
|
||||||
|
|
||||||
StartCommand = ReactiveCommand.CreateFromTask(
|
StartCommand = ReactiveCommand.CreateFromTask(
|
||||||
RunAsync,
|
RunAsync,
|
||||||
this.WhenAnyValue(x => x.IsRunning).Select(running => !running));
|
this.WhenAnyValue(x => x.IsRunning).Select(running => !running));
|
||||||
@@ -59,6 +61,8 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
|
|||||||
ObserveCommandFailures();
|
ObserveCommandFailures();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<MetadataScanScopeOption> Scopes => MetadataScanScopeOption.All;
|
||||||
|
|
||||||
/// <summary>Videos the run found something for, newest first.</summary>
|
/// <summary>Videos the run found something for, newest first.</summary>
|
||||||
public ObservableCollection<MetadataScanResultViewModel> Results { get; } = [];
|
public ObservableCollection<MetadataScanResultViewModel> Results { get; } = [];
|
||||||
|
|
||||||
@@ -78,9 +82,9 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
|
|||||||
[Reactive]
|
[Reactive]
|
||||||
public partial string StatusText { get; set; } = "Опрос источников по отпечаткам всей библиотеки.";
|
public partial string StatusText { get; set; } = "Опрос источников по отпечаткам всей библиотеки.";
|
||||||
|
|
||||||
/// <summary>Skip videos that already have a description; on by default.</summary>
|
/// <summary>What the run should cover; the narrowest option by default.</summary>
|
||||||
[Reactive]
|
[Reactive]
|
||||||
public partial bool OnlyWithoutDescription { get; set; } = true;
|
public partial MetadataScanScopeOption SelectedScope { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Write a result without asking when exactly one candidate came back. Off by default —
|
/// Write a result without asking when exactly one candidate came back. Off by default —
|
||||||
@@ -100,7 +104,7 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
|
|||||||
Problems.Clear();
|
Problems.Clear();
|
||||||
StatusText = "Идёт опрос источников…";
|
StatusText = "Идёт опрос источников…";
|
||||||
|
|
||||||
var request = new MetadataScanRequest(OnlyWithoutDescription, ApplyUnambiguous);
|
var request = new MetadataScanRequest(SelectedScope.Scope, ApplyUnambiguous);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -159,7 +163,7 @@ public sealed partial class MetadataScanViewModel : ViewModelBase
|
|||||||
case MetadataScanEvent.Completed completed:
|
case MetadataScanEvent.Completed completed:
|
||||||
Progress = 100;
|
Progress = 100;
|
||||||
StatusText = completed.Processed == 0
|
StatusText = completed.Processed == 0
|
||||||
? "Нечего проверять — у видео ещё нет отпечатков, либо все уже описаны"
|
? "Нечего проверять — у видео ещё нет отпечатков, либо ко всем уже применены метаданные"
|
||||||
: $"Проверено {completed.Processed}, совпадения у {completed.WithMatches}, применено {completed.Applied}";
|
: $"Проверено {completed.Processed}, совпадения у {completed.WithMatches}, применено {completed.Applied}";
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -40,11 +40,24 @@
|
|||||||
Text="{Binding StatusText}" />
|
Text="{Binding StatusText}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="18">
|
<StackPanel Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
|
||||||
<CheckBox Content="Только без описания" IsChecked="{Binding OnlyWithoutDescription}" />
|
<TextBlock Classes="cardMeta" VerticalAlignment="Center" Text="Опрашивать:" />
|
||||||
|
<ComboBox MinWidth="300"
|
||||||
|
ItemsSource="{Binding Scopes}"
|
||||||
|
SelectedItem="{Binding SelectedScope}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:MetadataScanScopeOption">
|
||||||
|
<TextBlock Text="{Binding Label}" ToolTip.Tip="{Binding Hint}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
<CheckBox Content="Применять однозначные сразу" IsChecked="{Binding ApplyUnambiguous}" />
|
<CheckBox Content="Применять однозначные сразу" IsChecked="{Binding ApplyUnambiguous}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Classes="cardMeta"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="{Binding SelectedScope.Hint}" />
|
||||||
|
|
||||||
<!-- Sources that dropped out. Reported once each: a rejected key fails on every
|
<!-- Sources that dropped out. Reported once each: a rejected key fails on every
|
||||||
video, and a line per video would bury the results. -->
|
video, and a line per video would bury the results. -->
|
||||||
<ItemsControl ItemsSource="{Binding Problems}">
|
<ItemsControl ItemsSource="{Binding Problems}">
|
||||||
@@ -74,15 +87,41 @@
|
|||||||
Margin="0,0,0,10">
|
Margin="0,0,0,10">
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
|
<!--
|
||||||
<TextBlock Grid.Column="0"
|
Ours, captioned. Deciding whether a proposal is the same video is done by
|
||||||
Text="{Binding VideoTitle}"
|
eye, so the file's own frame has to be on the page — and every picture here
|
||||||
FontSize="13"
|
says where it came from, because a page of unlabelled frames is worse than
|
||||||
FontWeight="SemiBold"
|
one with no frames at all.
|
||||||
TextWrapping="Wrap"
|
-->
|
||||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="10">
|
||||||
|
|
||||||
<Border Grid.Column="1"
|
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Top">
|
||||||
|
<Border Width="128"
|
||||||
|
Height="72"
|
||||||
|
CornerRadius="7"
|
||||||
|
ClipToBounds="True"
|
||||||
|
BorderThickness="2"
|
||||||
|
BorderBrush="{DynamicResource AccentBrush}"
|
||||||
|
Background="{DynamicResource ThumbnailPlaceholderBrush}">
|
||||||
|
<controls:AsyncImage Source="{Binding VideoThumbnailPath}" DecodeWidth="256" />
|
||||||
|
</Border>
|
||||||
|
<TextBlock Classes="imageCaption"
|
||||||
|
Foreground="{DynamicResource AccentBrush}"
|
||||||
|
Text="В библиотеке" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" Spacing="3" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding VideoTitle}"
|
||||||
|
FontSize="13"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||||
|
<TextBlock Classes="cardMeta"
|
||||||
|
Text="Источники расходятся — выберите сами"
|
||||||
|
IsVisible="{Binding IsAmbiguous}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Border Grid.Column="2"
|
||||||
Classes="badge"
|
Classes="badge"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
IsVisible="{Binding AppliedFrom, Converter={x:Static ObjectConverters.IsNotNull}}">
|
IsVisible="{Binding AppliedFrom, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
@@ -90,27 +129,31 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBlock Classes="cardMeta"
|
<!-- The line between what we have and what is being proposed. -->
|
||||||
Text="Источники расходятся — выберите сами"
|
<Rectangle Height="1" Fill="{DynamicResource SurfaceBorderBrush}" />
|
||||||
IsVisible="{Binding IsAmbiguous}" />
|
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding Matches}">
|
<ItemsControl ItemsSource="{Binding Matches}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:MetadataMatchViewModel">
|
<DataTemplate x:DataType="vm:MetadataMatchViewModel">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="10" Margin="0,0,0,6">
|
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="10" Margin="0,0,0,6">
|
||||||
|
|
||||||
<!-- The candidate's own cover. Absent for a source that has none, and
|
<!-- Theirs, captioned with the source. Neutral border against the
|
||||||
the row simply loses the column rather than reserving a blank. -->
|
accented one above, so the two can never be read the same way. -->
|
||||||
<Border Grid.Column="0"
|
<StackPanel Grid.Column="0"
|
||||||
Width="128"
|
Spacing="3"
|
||||||
Height="72"
|
VerticalAlignment="Top"
|
||||||
CornerRadius="7"
|
IsVisible="{Binding ImagePath, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
ClipToBounds="True"
|
<Border Width="128"
|
||||||
VerticalAlignment="Top"
|
Height="72"
|
||||||
Background="{DynamicResource ThumbnailPlaceholderBrush}"
|
CornerRadius="7"
|
||||||
IsVisible="{Binding ImagePath, Converter={x:Static ObjectConverters.IsNotNull}}">
|
ClipToBounds="True"
|
||||||
<controls:AsyncImage Source="{Binding ImagePath}" DecodeWidth="256" />
|
BorderThickness="1"
|
||||||
</Border>
|
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||||
|
Background="{DynamicResource ThumbnailPlaceholderBrush}">
|
||||||
|
<controls:AsyncImage Source="{Binding ImagePath}" DecodeWidth="256" />
|
||||||
|
</Border>
|
||||||
|
<TextBlock Classes="imageCaption" Text="{Binding SourceName}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Column="1" Spacing="2">
|
<StackPanel Grid.Column="1" Spacing="2">
|
||||||
<TextBlock Text="{Binding Title}"
|
<TextBlock Text="{Binding Title}"
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace PLib.Domain.Videos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A record that one metadata source's answer was written onto a video.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A set rather than a single "last applied" field, because the question a run asks is per
|
||||||
|
/// source: this video already has what PornDB knows, so ask StashDB and leave PornDB alone.
|
||||||
|
/// One field would answer that only until a second source was applied, after which the first
|
||||||
|
/// would be forgotten and offered all over again.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AppliedMetadataSource
|
||||||
|
{
|
||||||
|
/// <summary>Required by EF Core materialization; do not use from application code.</summary>
|
||||||
|
private AppliedMetadataSource() => SourceName = null!;
|
||||||
|
|
||||||
|
public AppliedMetadataSource(string sourceName, DateTimeOffset appliedAt)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
|
||||||
|
|
||||||
|
SourceName = sourceName.Trim();
|
||||||
|
AppliedAt = appliedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The configured source's name, as the user wrote it in the settings.</summary>
|
||||||
|
public string SourceName { get; private set; }
|
||||||
|
|
||||||
|
public DateTimeOffset AppliedAt { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matched case-insensitively: the name is free text the user typed, and "PornDb" and
|
||||||
|
/// "PornDB" are plainly the same source to everyone but a byte comparison.
|
||||||
|
/// </summary>
|
||||||
|
public bool Is(string sourceName) =>
|
||||||
|
string.Equals(SourceName, sourceName?.Trim(), StringComparison.CurrentCultureIgnoreCase);
|
||||||
|
|
||||||
|
internal void RefreshedAt(DateTimeOffset appliedAt) => AppliedAt = appliedAt;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ public sealed class VideoItem
|
|||||||
private static readonly TimeSpan ResumeThreshold = TimeSpan.FromSeconds(20);
|
private static readonly TimeSpan ResumeThreshold = TimeSpan.FromSeconds(20);
|
||||||
|
|
||||||
private readonly List<LibraryLabel> _labels = [];
|
private readonly List<LibraryLabel> _labels = [];
|
||||||
|
private readonly List<AppliedMetadataSource> _appliedSources = [];
|
||||||
|
|
||||||
/// <summary>Required by EF Core materialization; do not use from application code.</summary>
|
/// <summary>Required by EF Core materialization; do not use from application code.</summary>
|
||||||
private VideoItem()
|
private VideoItem()
|
||||||
@@ -57,6 +58,16 @@ public sealed class VideoItem
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Description { get; private set; }
|
public string? Description { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which metadata sources have had an answer written onto this video, and when.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Recorded in its own right rather than inferred from the description. A source that has
|
||||||
|
/// a title, performers and tags but no synopsis leaves the description empty, and taking
|
||||||
|
/// that to mean "never matched" is what made such videos come back on every run.
|
||||||
|
/// </remarks>
|
||||||
|
public IReadOnlyCollection<AppliedMetadataSource> AppliedSources => _appliedSources;
|
||||||
|
|
||||||
public long SizeInBytes { get; private set; }
|
public long SizeInBytes { get; private set; }
|
||||||
|
|
||||||
public TimeSpan? Duration { get; private set; }
|
public TimeSpan? Duration { get; private set; }
|
||||||
@@ -139,6 +150,29 @@ public sealed class VideoItem
|
|||||||
Title = title;
|
Title = title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>True once any source's answer has been written onto this video.</summary>
|
||||||
|
public bool HasMetadata => _appliedSources.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>True when this particular source has already had its say.</summary>
|
||||||
|
public bool HasMetadataFrom(string sourceName) =>
|
||||||
|
_appliedSources.Any(applied => applied.Is(sourceName));
|
||||||
|
|
||||||
|
/// <summary>Records that a match from <paramref name="sourceName"/> was written on.</summary>
|
||||||
|
public void MarkMetadataApplied(string sourceName)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
|
||||||
|
|
||||||
|
// Re-applying the same source moves its timestamp rather than adding a second entry:
|
||||||
|
// the set answers "has this source had its say", and twice is the same as once.
|
||||||
|
if (_appliedSources.FirstOrDefault(applied => applied.Is(sourceName)) is { } existing)
|
||||||
|
{
|
||||||
|
existing.RefreshedAt(DateTimeOffset.UtcNow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_appliedSources.Add(new AppliedMetadataSource(sourceName, DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Replaces the description; blank clears it rather than storing whitespace.</summary>
|
/// <summary>Replaces the description; blank clears it rather than storing whitespace.</summary>
|
||||||
public void Describe(string? description) =>
|
public void Describe(string? description) =>
|
||||||
Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
|
Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
|
||||||
@@ -254,5 +288,8 @@ public sealed class VideoItem
|
|||||||
ApplyPerceptualHash(null);
|
ApplyPerceptualHash(null);
|
||||||
DetachThumbnail();
|
DetachThumbnail();
|
||||||
DetachPreview();
|
DetachPreview();
|
||||||
|
|
||||||
|
// The fingerprint that earned those answers described the previous file.
|
||||||
|
_appliedSources.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,23 @@ public sealed class EfVideoRepository(LibraryDbContext dbContext) : IVideoReposi
|
|||||||
.OrderByDescending(x => x.AddedAt)
|
.OrderByDescending(x => x.AddedAt)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<VideoItem>> GetAllWithMetadataSourcesAsync(
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
await dbContext.Videos
|
||||||
|
.Include(x => x.AppliedSources)
|
||||||
|
.OrderByDescending(x => x.AddedAt)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
|
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
|
||||||
dbContext.Videos.FirstOrDefaultAsync(x => x.FullPath == fullPath, cancellationToken);
|
dbContext.Videos.FirstOrDefaultAsync(x => x.FullPath == fullPath, cancellationToken);
|
||||||
|
|
||||||
public Task<VideoItem?> FindWithLabelsAsync(Guid id, CancellationToken cancellationToken = default) =>
|
public Task<VideoItem?> FindWithLabelsAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||||
dbContext.Videos
|
dbContext.Videos
|
||||||
.Include(x => x.Labels)
|
.Include(x => x.Labels)
|
||||||
|
|
||||||
|
// Applying a match writes here too, and without the collection loaded EF would
|
||||||
|
// add a second row for a source that has already had its say.
|
||||||
|
.Include(x => x.AppliedSources)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
|
||||||
public async Task AddAsync(VideoItem item, CancellationToken cancellationToken = default) =>
|
public async Task AddAsync(VideoItem item, CancellationToken cancellationToken = default) =>
|
||||||
|
|||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using PLib.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PLib.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibraryDbContext))]
|
||||||
|
[Migration("20260810080601_MetadataApplied")]
|
||||||
|
partial class MetadataApplied
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||||
|
|
||||||
|
modelBuilder.Entity("LibraryLabelVideoItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("LabelsId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("VideosId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("LabelsId", "VideosId");
|
||||||
|
|
||||||
|
b.HasIndex("VideosId");
|
||||||
|
|
||||||
|
b.ToTable("VideoLabels", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PLib.Domain.Videos.LibraryLabel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ImagePath")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Kind", "NormalizedName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Labels", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PLib.Domain.Videos.VideoItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<TimeSpan?>("Duration")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FileModifiedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("FullPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Height")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPlayedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MetadataAppliedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("MetadataSource")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long?>("PerceptualHash")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PlayCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PreviewFrameCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PreviewPath")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<TimeSpan?>("ResumePosition")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SizeInBytes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ThumbnailPath")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("VideoCodec")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Width")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AddedAt");
|
||||||
|
|
||||||
|
b.HasIndex("FullPath")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("LastPlayedAt");
|
||||||
|
|
||||||
|
b.HasIndex("PerceptualHash");
|
||||||
|
|
||||||
|
b.ToTable("Videos", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LibraryLabelVideoItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PLib.Domain.Videos.LibraryLabel", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LabelsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PLib.Domain.Videos.VideoItem", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("VideosId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PLib.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MetadataApplied : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "MetadataAppliedAt",
|
||||||
|
table: "Videos",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "MetadataSource",
|
||||||
|
table: "Videos",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// Videos matched before this column existed would otherwise all look untouched and
|
||||||
|
// be offered again on the next run. A description is the only trace such a match
|
||||||
|
// left behind, so it stands in for one — the timestamp is "now" in .NET ticks,
|
||||||
|
// which is the unix epoch plus 62135596800 seconds. The source stays unknown,
|
||||||
|
// because it genuinely is.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
UPDATE "Videos"
|
||||||
|
SET "MetadataAppliedAt" = (strftime('%s', 'now') + 62135596800) * 10000000
|
||||||
|
WHERE "Description" IS NOT NULL AND trim("Description") <> ''
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MetadataAppliedAt",
|
||||||
|
table: "Videos");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MetadataSource",
|
||||||
|
table: "Videos");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+202
@@ -0,0 +1,202 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using PLib.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PLib.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibraryDbContext))]
|
||||||
|
[Migration("20260810082839_AppliedMetadataSources")]
|
||||||
|
partial class AppliedMetadataSources
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||||
|
|
||||||
|
modelBuilder.Entity("LibraryLabelVideoItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("LabelsId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("VideosId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("LabelsId", "VideosId");
|
||||||
|
|
||||||
|
b.HasIndex("VideosId");
|
||||||
|
|
||||||
|
b.ToTable("VideoLabels", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PLib.Domain.Videos.LibraryLabel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ImagePath")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Kind", "NormalizedName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Labels", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PLib.Domain.Videos.VideoItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("AddedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<TimeSpan?>("Duration")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("FileModifiedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("FullPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Height")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPlayedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("PerceptualHash")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PlayCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PreviewFrameCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PreviewPath")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<TimeSpan?>("ResumePosition")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SizeInBytes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ThumbnailPath")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("VideoCodec")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("Width")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AddedAt");
|
||||||
|
|
||||||
|
b.HasIndex("FullPath")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("LastPlayedAt");
|
||||||
|
|
||||||
|
b.HasIndex("PerceptualHash");
|
||||||
|
|
||||||
|
b.ToTable("Videos", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("LibraryLabelVideoItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PLib.Domain.Videos.LibraryLabel", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LabelsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PLib.Domain.Videos.VideoItem", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("VideosId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PLib.Domain.Videos.VideoItem", b =>
|
||||||
|
{
|
||||||
|
b.OwnsMany("PLib.Domain.Videos.AppliedMetadataSource", "AppliedSources", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<long>("AppliedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("SourceName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<Guid>("VideoId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("Id");
|
||||||
|
|
||||||
|
b1.HasIndex("VideoId", "SourceName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b1.ToTable("VideoMetadataSources", (string)null);
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("VideoId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("AppliedSources");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PLib.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AppliedMetadataSources : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Deliberately reordered from what was scaffolded: the generated version dropped
|
||||||
|
// the two columns first and created the table afterwards, which would have thrown
|
||||||
|
// away every record of what had already been applied.
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "VideoMetadataSources",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
SourceName = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
|
||||||
|
AppliedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
VideoId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_VideoMetadataSources", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_VideoMetadataSources_Videos_VideoId",
|
||||||
|
column: x => x.VideoId,
|
||||||
|
principalTable: "Videos",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_VideoMetadataSources_VideoId_SourceName",
|
||||||
|
table: "VideoMetadataSources",
|
||||||
|
columns: new[] { "VideoId", "SourceName" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
// The single-source column becomes the first row of the set. Rows that recorded a
|
||||||
|
// time but no name came from the previous migration's backfill, which inferred the
|
||||||
|
// fact from a description and genuinely could not know the source.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
INSERT INTO "VideoMetadataSources" ("VideoId", "SourceName", "AppliedAt")
|
||||||
|
SELECT "Id", COALESCE(NULLIF(trim("MetadataSource"), ''), 'неизвестный источник'), "MetadataAppliedAt"
|
||||||
|
FROM "Videos"
|
||||||
|
WHERE "MetadataAppliedAt" IS NOT NULL
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MetadataAppliedAt",
|
||||||
|
table: "Videos");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MetadataSource",
|
||||||
|
table: "Videos");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "MetadataAppliedAt",
|
||||||
|
table: "Videos",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "MetadataSource",
|
||||||
|
table: "Videos",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 128,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// Going back keeps the most recent source of each video; the rest cannot fit.
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
UPDATE "Videos"
|
||||||
|
SET "MetadataAppliedAt" = (
|
||||||
|
SELECT max("AppliedAt") FROM "VideoMetadataSources" AS "s"
|
||||||
|
WHERE "s"."VideoId" = "Videos"."Id"),
|
||||||
|
"MetadataSource" = (
|
||||||
|
SELECT "s"."SourceName" FROM "VideoMetadataSources" AS "s"
|
||||||
|
WHERE "s"."VideoId" = "Videos"."Id"
|
||||||
|
ORDER BY "s"."AppliedAt" DESC LIMIT 1)
|
||||||
|
WHERE EXISTS (SELECT 1 FROM "VideoMetadataSources" AS "s" WHERE "s"."VideoId" = "Videos"."Id")
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "VideoMetadataSources");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,6 +160,39 @@ namespace PLib.Infrastructure.Persistence.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PLib.Domain.Videos.VideoItem", b =>
|
||||||
|
{
|
||||||
|
b.OwnsMany("PLib.Domain.Videos.AppliedMetadataSource", "AppliedSources", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<long>("AppliedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("SourceName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<Guid>("VideoId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("Id");
|
||||||
|
|
||||||
|
b1.HasIndex("VideoId", "SourceName")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b1.ToTable("VideoMetadataSources", (string)null);
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("VideoId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("AppliedSources");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ internal sealed class VideoItemConfiguration : IEntityTypeConfiguration<VideoIte
|
|||||||
builder.Property(x => x.AddedAt).HasConversion(UtcTicksConverter);
|
builder.Property(x => x.AddedAt).HasConversion(UtcTicksConverter);
|
||||||
builder.Property(x => x.FileModifiedAt).HasConversion(UtcTicksConverter);
|
builder.Property(x => x.FileModifiedAt).HasConversion(UtcTicksConverter);
|
||||||
builder.Property(x => x.LastPlayedAt).HasConversion(NullableUtcTicksConverter);
|
builder.Property(x => x.LastPlayedAt).HasConversion(NullableUtcTicksConverter);
|
||||||
|
// Owned, not a separate aggregate: an applied source has no life of its own and
|
||||||
|
// is only ever read through the video it was applied to.
|
||||||
|
builder.OwnsMany(x => x.AppliedSources, applied =>
|
||||||
|
{
|
||||||
|
applied.ToTable("VideoMetadataSources");
|
||||||
|
applied.WithOwner().HasForeignKey("VideoId");
|
||||||
|
applied.Property<int>("Id");
|
||||||
|
applied.HasKey("Id");
|
||||||
|
|
||||||
|
applied.Property(x => x.SourceName).IsRequired().HasMaxLength(128);
|
||||||
|
applied.Property(x => x.AppliedAt).HasConversion(UtcTicksConverter);
|
||||||
|
|
||||||
|
// One row per source per video; re-applying moves the timestamp.
|
||||||
|
applied.HasIndex("VideoId", nameof(AppliedMetadataSource.SourceName)).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
// SQLite integers are signed, so the hash is stored as its bit pattern rather than
|
// SQLite integers are signed, so the hash is stored as its bit pattern rather than
|
||||||
// its value — reinterpreted, never converted, so no bits are lost either way.
|
// its value — reinterpreted, never converted, so no bits are lost either way.
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ internal sealed class InMemoryVideoRepository : IVideoRepository
|
|||||||
public Task<IReadOnlyList<VideoItem>> GetAllWithLabelsAsync(CancellationToken cancellationToken = default) =>
|
public Task<IReadOnlyList<VideoItem>> GetAllWithLabelsAsync(CancellationToken cancellationToken = default) =>
|
||||||
GetAllAsync(cancellationToken);
|
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) =>
|
public Task<VideoItem?> FindByPathAsync(string fullPath, CancellationToken cancellationToken = default) =>
|
||||||
Task.FromResult(_items.GetValueOrDefault(fullPath));
|
Task.FromResult(_items.GetValueOrDefault(fullPath));
|
||||||
|
|
||||||
|
|||||||
@@ -39,21 +39,57 @@ public sealed class MetadataScanTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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);
|
var done = Video("размеченное", hash: 1);
|
||||||
described.Describe("уже есть");
|
done.MarkMetadataApplied("StashDB");
|
||||||
_videos.Seed(described, Video("голое", hash: 2));
|
_videos.Seed(done, Video("голое", hash: 2));
|
||||||
|
|
||||||
Answer(_source, []);
|
Answer(_source, []);
|
||||||
|
|
||||||
(await CollectAsync()).OfType<MetadataScanEvent.Completed>().Single().Processed.ShouldBe(1);
|
(await CollectAsync()).OfType<MetadataScanEvent.Completed>().Single().Processed.ShouldBe(1);
|
||||||
|
|
||||||
// Asking again for everything is what the switch is for.
|
// 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);
|
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]
|
[Fact]
|
||||||
public async Task A_single_candidate_is_written_only_when_the_run_was_told_it_may()
|
public async Task A_single_candidate_is_written_only_when_the_run_was_told_it_may()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user