Implement metadata management features in PLib video library manager. Introduce functionality to query and apply metadata from external sources based on video fingerprints. Update ILibraryService and LibraryService to support metadata lookup and application, enhancing video item descriptions and labels. Revise UI components in VideoPlayerView and SettingsView to facilitate user interaction with metadata sources. Update README.md to document new metadata features and usage instructions.
This commit is contained in:
@@ -28,6 +28,7 @@
|
|||||||
<PackageVersion Include="DynamicData" Version="9.4.33" />
|
<PackageVersion Include="DynamicData" Version="9.4.33" />
|
||||||
<PackageVersion Include="System.Reactive" Version="7.0.0" />
|
<PackageVersion Include="System.Reactive" Version="7.0.0" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
в `settings.json` и подхватывается без перезапуска.
|
в `settings.json` и подхватывается без перезапуска.
|
||||||
- Очистка собранных данных по видам — постеры, анимированные превью, отпечатки, технические
|
- Очистка собранных данных по видам — постеры, анимированные превью, отпечатки, технические
|
||||||
метаданные — каждый со своей кнопкой и текущим объёмом.
|
метаданные — каждый со своей кнопкой и текущим объёмом.
|
||||||
|
- Источники метаданных: список GraphQL-эндпойнтов (название, адрес, API-ключ) со схемой
|
||||||
|
stash-box. Поиск по отпечатку запускается кнопкой на странице видео; найденное показывается
|
||||||
|
списком, и применяется тем, что выбрали — название, описание, теги, актёры, студия.
|
||||||
- Светлая, тёмная и системная темы; выбор запоминается.
|
- Светлая, тёмная и системная темы; выбор запоминается.
|
||||||
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
|
- Встроенный плеер: клик по карточке открывает страницу медиа прямо в окне — видео,
|
||||||
перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход —
|
перемотка, громкость, кнопка «назад». Полноэкранный режим по F11 или кнопке, выход —
|
||||||
@@ -128,12 +131,29 @@ dotnet test
|
|||||||
системой непересекающихся множеств, чтобы цепочка «A похож на B, B на C» дала одну группу.
|
системой непересекающихся множеств, чтобы цепочка «A похож на B, B на C» дала одну группу.
|
||||||
**Побитовая совместимость со stash не проверена** — разные реализации ресайза способны
|
**Побитовая совместимость со stash не проверена** — разные реализации ресайза способны
|
||||||
перевернуть биты у коэффициентов рядом с медианой.
|
перевернуть биты у коэффициентов рядом с медианой.
|
||||||
- **Теги и коллекции — одна сущность.** `LibraryLabel` с `LabelKind`: связь с видео у них
|
- **Теги, коллекции, актёры и студии — одна сущность.** `LibraryLabel` с `LabelKind`: связь
|
||||||
одинаковая, различается только назначение. Одна сущность — одна таблица связей, один
|
с видео у них одинаковая, различается только назначение. Одна сущность — одна таблица
|
||||||
репозиторий и одно правило именования; разделить потом можно переименованием и миграцией,
|
связей, один репозиторий и одно правило именования; разделить потом можно переименованием
|
||||||
а держать два почти одинаковых агрегата синхронными пришлось бы всегда. Уникальность —
|
и миграцией, а держать четыре почти одинаковых агрегата синхронными пришлось бы всегда.
|
||||||
по нормализованному имени в паре с видом, так что «Комедия» и «комедия» не разойдутся,
|
Появление актёров и студий это подтвердило: два новых значения перечисления, ноль новых
|
||||||
а тег и коллекция с одним именем сосуществуют.
|
таблиц. Уникальность — по нормализованному имени в паре с видом, так что «Комедия» и
|
||||||
|
«комедия» не разойдутся, а тег и студия с одним именем сосуществуют.
|
||||||
|
- **Метаданные — только по кнопке.** Никакой фоновой синхронизации: обращение к чужому
|
||||||
|
серверу по поводу файлов пользователя происходит тогда, когда он нажал «Найти метаданные»,
|
||||||
|
и больше никогда. Уходит один отпечаток — 16 шестнадцатеричных цифр; ни имён файлов, ни
|
||||||
|
самих файлов.
|
||||||
|
Источники опрашиваются по очереди и независимо: упавший попадает в список «не ответили»,
|
||||||
|
но не прячет то, что нашли остальные. GraphQL отвечает двухсотым и массивом `errors`,
|
||||||
|
поэтому он разбирается явно — иначе неверный ключ читался бы как «источник ничего не знает».
|
||||||
|
Найденное не применяется само: отпечатки совпадают у перекодировок и трейлеров, а молча
|
||||||
|
переписанное название откатывать куда дороже, чем нажать кнопку. Применение добавляет метки,
|
||||||
|
но не удаляет чужие — то, что проставил пользователь, остаётся.
|
||||||
|
Схема — stash-box (StashDB и родственники): именно поэтому список источников вообще имеет
|
||||||
|
смысл, ведь это разные экземпляры одного сервера, отвечающие на один и тот же запрос.
|
||||||
|
GraphQL-клиента в зависимостях нет: весь разговор — один POST с `{query, variables}` и один
|
||||||
|
объект в ответе.
|
||||||
|
**API-ключи лежат в `settings.json` открытым текстом** — там же и с той же защитой, что и
|
||||||
|
остальные настройки, то есть правами файловой системы.
|
||||||
- **Наблюдатель говорит только «посмотри снова».** `FileSystemWatcher` шлёт несколько
|
- **Наблюдатель говорит только «посмотри снова».** `FileSystemWatcher` шлёт несколько
|
||||||
событий на файл, а копирование — поток событий на всё время копирования. Восстанавливать
|
событий на файл, а копирование — поток событий на всё время копирования. Восстанавливать
|
||||||
из этого точную дельту — гадание, поэтому события гасятся тремя секундами тишины, а
|
из этого точную дельту — гадание, поэтому события гасятся тремя секундами тишины, а
|
||||||
@@ -161,7 +181,8 @@ dotnet test
|
|||||||
- `library.db` — SQLite с метаданными, метками, прогрессом просмотра и pHash;
|
- `library.db` — SQLite с метаданными, метками, прогрессом просмотра и pHash;
|
||||||
- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла);
|
- `thumbnails/` — кэш постеров (ключ = путь + размер + время изменения файла);
|
||||||
- `previews/` — кэш анимированных превью, тот же ключ плюс число кадров в имени;
|
- `previews/` — кэш анимированных превью, тот же ключ плюс число кадров в имени;
|
||||||
- `settings.json` — папки, параметры превью и сканирования, тема, громкость;
|
- `settings.json` — папки, параметры превью и сканирования, тема, громкость, источники
|
||||||
|
метаданных вместе с их API-ключами;
|
||||||
перечитывается на лету;
|
перечитывается на лету;
|
||||||
- `logs/` — Serilog, ротация по дням.
|
- `logs/` — Serilog, ротация по дням.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using PLib.Application.Metadata;
|
||||||
|
|
||||||
|
namespace PLib.Application.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>Asks a configured source what it knows about a video, by fingerprint.</summary>
|
||||||
|
public interface IMetadataProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Everything <paramref name="source"/> has for the given perceptual hash. An empty list
|
||||||
|
/// means the source answered and knows nothing; a failure to reach or understand it is
|
||||||
|
/// thrown, because those two outcomes call for different things from the user.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<VideoMetadataMatch>> FindByPerceptualHashAsync(
|
||||||
|
MetadataSourceOptions source,
|
||||||
|
ulong perceptualHash,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using PLib.Application.Metadata;
|
||||||
using PLib.Domain.Videos;
|
using PLib.Domain.Videos;
|
||||||
|
|
||||||
namespace PLib.Application.Library;
|
namespace PLib.Application.Library;
|
||||||
@@ -61,4 +62,22 @@ public interface ILibraryService
|
|||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default);
|
Task DetachLabelAsync(Guid videoId, Guid labelId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks every configured metadata source what it has for this video's fingerprint.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Nothing calls this on its own: reaching out to a remote service about the user's files
|
||||||
|
/// happens when the user presses the button, and at no other time.
|
||||||
|
/// </remarks>
|
||||||
|
Task<MetadataLookupResult> FindMetadataAsync(Guid videoId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a match onto the video: title, description, and a label per tag, performer and
|
||||||
|
/// studio. Labels already on the video are kept — applying a match adds, never prunes.
|
||||||
|
/// </summary>
|
||||||
|
Task ApplyMetadataAsync(
|
||||||
|
Guid videoId,
|
||||||
|
VideoMetadataMatch match,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Threading.Channels;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using PLib.Application.Abstractions;
|
using PLib.Application.Abstractions;
|
||||||
|
using PLib.Application.Metadata;
|
||||||
using PLib.Domain.Videos;
|
using PLib.Domain.Videos;
|
||||||
|
|
||||||
namespace PLib.Application.Library;
|
namespace PLib.Application.Library;
|
||||||
@@ -16,7 +17,9 @@ public sealed class LibraryService(
|
|||||||
IThumbnailGenerator thumbnailGenerator,
|
IThumbnailGenerator thumbnailGenerator,
|
||||||
IAnimatedPreviewGenerator previewGenerator,
|
IAnimatedPreviewGenerator previewGenerator,
|
||||||
IVideoPerceptualHasher perceptualHasher,
|
IVideoPerceptualHasher perceptualHasher,
|
||||||
|
IMetadataProvider metadataProvider,
|
||||||
IOptions<LibraryOptions> options,
|
IOptions<LibraryOptions> options,
|
||||||
|
IOptionsMonitor<MetadataOptions> metadataOptions,
|
||||||
ILogger<LibraryService> logger) : ILibraryService
|
ILogger<LibraryService> logger) : ILibraryService
|
||||||
{
|
{
|
||||||
/// <summary>How many indexed items to accumulate before flushing them to storage.</summary>
|
/// <summary>How many indexed items to accumulate before flushing them to storage.</summary>
|
||||||
@@ -147,6 +150,101 @@ public sealed class LibraryService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<MetadataLookupResult> FindMetadataAsync(
|
||||||
|
Guid videoId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var video = await repository.FindWithLabelsAsync(videoId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Video {videoId} is not in the library");
|
||||||
|
|
||||||
|
if (video.PerceptualHash is not { } hash)
|
||||||
|
{
|
||||||
|
return new MetadataLookupResult(HasPerceptualHash: false, [], []);
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches = new List<VideoMetadataMatch>();
|
||||||
|
var failures = new List<string>();
|
||||||
|
|
||||||
|
// Sequentially and in configured order: the list is short, the sources are somebody
|
||||||
|
// else's servers, and a predictable order is worth more here than a few saved seconds.
|
||||||
|
foreach (var source in metadataOptions.CurrentValue.Sources.Where(source => source.IsUsable))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
matches.AddRange(
|
||||||
|
await metadataProvider.FindByPerceptualHashAsync(source, hash, cancellationToken));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// One source being down must not hide what the others found.
|
||||||
|
logger.LogWarning(ex, "Metadata source {Source} could not be queried", source.Name);
|
||||||
|
failures.Add($"{source.Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MetadataLookupResult(HasPerceptualHash: true, matches, failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ApplyMetadataAsync(
|
||||||
|
Guid videoId,
|
||||||
|
VideoMetadataMatch match,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(match);
|
||||||
|
|
||||||
|
var video = await repository.FindWithLabelsAsync(videoId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Video {videoId} is not in the library");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(match.Title))
|
||||||
|
{
|
||||||
|
video.Rename(match.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
video.Describe(match.Description);
|
||||||
|
|
||||||
|
await AttachAllAsync(video, match.Tags, LabelKind.Tag, cancellationToken);
|
||||||
|
await AttachAllAsync(video, match.Performers, LabelKind.Performer, cancellationToken);
|
||||||
|
await AttachAllAsync(video, match.Studios, LabelKind.Studio, cancellationToken);
|
||||||
|
|
||||||
|
// One save for the whole match: half an applied match is worse than none, because
|
||||||
|
// nothing on screen would say which half.
|
||||||
|
await repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Applied metadata from {Source} to {Video}", match.SourceName, video.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AttachAllAsync(
|
||||||
|
VideoItem video,
|
||||||
|
IEnumerable<string> names,
|
||||||
|
LabelKind kind,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// A source can repeat a name within one match, and the video may already carry it;
|
||||||
|
// both have to collapse onto a single label.
|
||||||
|
var distinct = names
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.Distinct(StringComparer.CurrentCultureIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var name in distinct)
|
||||||
|
{
|
||||||
|
var label = await labels.FindAsync(kind, name, cancellationToken);
|
||||||
|
|
||||||
|
if (label is null)
|
||||||
|
{
|
||||||
|
label = new LibraryLabel(name, kind);
|
||||||
|
await labels.AddAsync(label, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
video.AddLabel(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<LibraryDataUsage>> GetDataUsageAsync(
|
public async Task<IReadOnlyList<LibraryDataUsage>> GetDataUsageAsync(
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace PLib.Application.Metadata;
|
||||||
|
|
||||||
|
/// <summary>Where PLib may go looking for information about a video it has on disk.</summary>
|
||||||
|
public sealed class MetadataOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Metadata";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The configured sources, in the order they were added. Empty by default: talking to a
|
||||||
|
/// remote service about the user's files is something they have to ask for.
|
||||||
|
/// </summary>
|
||||||
|
public IList<MetadataSourceOptions> Sources { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One GraphQL endpoint that can be asked about a video.</summary>
|
||||||
|
public sealed class MetadataSourceOptions
|
||||||
|
{
|
||||||
|
/// <summary>What to call it in the interface; the endpoint is no use as a label.</summary>
|
||||||
|
[Required]
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Absolute URL of the GraphQL endpoint.</summary>
|
||||||
|
[Required]
|
||||||
|
public string Endpoint { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sent as the <c>ApiKey</c> header. Stored in the settings file in plain text — the same
|
||||||
|
/// place and the same protection the rest of the settings get, which is the file system's.
|
||||||
|
/// </summary>
|
||||||
|
public string ApiKey { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>False for a source the user has switched off without deleting it.</summary>
|
||||||
|
public bool IsEnabled { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>True when this entry has enough filled in to be worth calling.</summary>
|
||||||
|
public bool IsUsable =>
|
||||||
|
IsEnabled &&
|
||||||
|
!string.IsNullOrWhiteSpace(Endpoint) &&
|
||||||
|
Uri.TryCreate(Endpoint, UriKind.Absolute, out _);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace PLib.Application.Metadata;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One candidate a source returned for a video, as PLib understands it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Deliberately flat and free of anything source-specific: a match is a proposal shown to the
|
||||||
|
/// user, and whichever schema it was decoded from stops mattering the moment it is decoded.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="SourceName">Which configured source proposed it.</param>
|
||||||
|
/// <param name="RemoteId">Its identifier at the source, for display and for reporting.</param>
|
||||||
|
public sealed record VideoMetadataMatch(
|
||||||
|
string SourceName,
|
||||||
|
string? RemoteId,
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
IReadOnlyList<string> Tags,
|
||||||
|
IReadOnlyList<string> Performers,
|
||||||
|
IReadOnlyList<string> Studios);
|
||||||
|
|
||||||
|
/// <summary>Everything one lookup produced, including what went wrong.</summary>
|
||||||
|
/// <param name="HasPerceptualHash">
|
||||||
|
/// False when the video has no fingerprint yet, which is the one failure the user can act on:
|
||||||
|
/// the answer is to let the scan finish, not to check the sources.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Matches">Candidates from every source that answered.</param>
|
||||||
|
/// <param name="Failures">
|
||||||
|
/// One line per source that could not be reached or did not understand the question. Sources
|
||||||
|
/// are independent, so one being down must not hide the answers from the others.
|
||||||
|
/// </param>
|
||||||
|
public sealed record MetadataLookupResult(
|
||||||
|
bool HasPerceptualHash,
|
||||||
|
IReadOnlyList<VideoMetadataMatch> Matches,
|
||||||
|
IReadOnlyList<string> Failures);
|
||||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
|
using PLib.Application.Metadata;
|
||||||
using PLib.Desktop.Settings;
|
using PLib.Desktop.Settings;
|
||||||
using PLib.Infrastructure.Storage;
|
using PLib.Infrastructure.Storage;
|
||||||
|
|
||||||
@@ -12,7 +13,8 @@ public sealed class JsonAppSettingsStore(
|
|||||||
IAppPaths paths,
|
IAppPaths paths,
|
||||||
IOptionsMonitor<LibraryOptions> library,
|
IOptionsMonitor<LibraryOptions> library,
|
||||||
IOptionsMonitor<AppearanceOptions> appearance,
|
IOptionsMonitor<AppearanceOptions> appearance,
|
||||||
IOptionsMonitor<PlaybackOptions> playback) : IAppSettingsStore
|
IOptionsMonitor<PlaybackOptions> playback,
|
||||||
|
IOptionsMonitor<MetadataOptions> metadata) : IAppSettingsStore
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true };
|
||||||
|
|
||||||
@@ -20,8 +22,11 @@ public sealed class JsonAppSettingsStore(
|
|||||||
|
|
||||||
private string SettingsFile => Path.Combine(paths.DataDirectory, "settings.json");
|
private string SettingsFile => Path.Combine(paths.DataDirectory, "settings.json");
|
||||||
|
|
||||||
public AppSettings Current =>
|
public AppSettings Current => AppSettings.From(
|
||||||
AppSettings.From(library.CurrentValue, appearance.CurrentValue, playback.CurrentValue);
|
library.CurrentValue,
|
||||||
|
appearance.CurrentValue,
|
||||||
|
playback.CurrentValue,
|
||||||
|
metadata.CurrentValue);
|
||||||
|
|
||||||
public async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default)
|
public async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -46,6 +51,19 @@ public sealed class JsonAppSettingsStore(
|
|||||||
playbackSection["Volume"] = Math.Round(settings.Volume, 3);
|
playbackSection["Volume"] = Math.Round(settings.Volume, 3);
|
||||||
playbackSection["IsMuted"] = settings.IsMuted;
|
playbackSection["IsMuted"] = settings.IsMuted;
|
||||||
|
|
||||||
|
// Written whole rather than merged per entry: the list has an order the user set
|
||||||
|
// and entries with no key of their own, so there is nothing to merge against.
|
||||||
|
Section(root, MetadataOptions.SectionName)["Sources"] = new JsonArray(
|
||||||
|
[
|
||||||
|
.. settings.MetadataSources.Select(source => (JsonNode)new JsonObject
|
||||||
|
{
|
||||||
|
["Name"] = source.Name,
|
||||||
|
["Endpoint"] = source.Endpoint,
|
||||||
|
["ApiKey"] = source.ApiKey,
|
||||||
|
["IsEnabled"] = source.IsEnabled,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
// Write through a temp file so an interrupted save cannot corrupt the settings.
|
// Write through a temp file so an interrupted save cannot corrupt the settings.
|
||||||
var staging = SettingsFile + ".tmp";
|
var staging = SettingsFile + ".tmp";
|
||||||
await File.WriteAllTextAsync(staging, root.ToJsonString(WriteOptions), cancellationToken);
|
await File.WriteAllTextAsync(staging, root.ToJsonString(WriteOptions), cancellationToken);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
|
using PLib.Application.Metadata;
|
||||||
|
|
||||||
namespace PLib.Desktop.Settings;
|
namespace PLib.Desktop.Settings;
|
||||||
|
|
||||||
@@ -27,10 +28,14 @@ public sealed record AppSettings
|
|||||||
|
|
||||||
public required bool IsMuted { get; init; }
|
public required bool IsMuted { get; init; }
|
||||||
|
|
||||||
|
/// <summary>GraphQL endpoints that may be asked about a video, in the order shown.</summary>
|
||||||
|
public required IReadOnlyList<MetadataSourceOptions> MetadataSources { get; init; }
|
||||||
|
|
||||||
public static AppSettings From(
|
public static AppSettings From(
|
||||||
LibraryOptions library,
|
LibraryOptions library,
|
||||||
AppearanceOptions appearance,
|
AppearanceOptions appearance,
|
||||||
PlaybackOptions playback) => new()
|
PlaybackOptions playback,
|
||||||
|
MetadataOptions metadata) => new()
|
||||||
{
|
{
|
||||||
Folders = [.. library.Folders],
|
Folders = [.. library.Folders],
|
||||||
ThumbnailWidth = library.ThumbnailWidth,
|
ThumbnailWidth = library.ThumbnailWidth,
|
||||||
@@ -40,6 +45,7 @@ public sealed record AppSettings
|
|||||||
Theme = appearance.Theme,
|
Theme = appearance.Theme,
|
||||||
Volume = playback.Volume,
|
Volume = playback.Volume,
|
||||||
IsMuted = playback.IsMuted,
|
IsMuted = playback.IsMuted,
|
||||||
|
MetadataSources = [.. metadata.Sources],
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using PLib.Application.Metadata;
|
||||||
|
using ReactiveUI;
|
||||||
|
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||||
|
|
||||||
|
namespace PLib.Desktop.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>One candidate returned by a metadata source, offered for the user to accept.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A match is never applied on arrival. Fingerprints collide across re-encodes and trailers,
|
||||||
|
/// and a source confidently overwriting a title is far harder to undo than a button is to press.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MetadataMatchViewModel
|
||||||
|
{
|
||||||
|
public MetadataMatchViewModel(VideoMetadataMatch match, Func<VideoMetadataMatch, Task> apply)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(match);
|
||||||
|
|
||||||
|
Match = match;
|
||||||
|
SourceName = match.SourceName;
|
||||||
|
Title = match.Title;
|
||||||
|
Description = match.Description;
|
||||||
|
|
||||||
|
Studios = Join(match.Studios);
|
||||||
|
Performers = Join(match.Performers);
|
||||||
|
Tags = Join(match.Tags);
|
||||||
|
|
||||||
|
ApplyCommand = ReactiveCommand.CreateFromTask(() => apply(match));
|
||||||
|
}
|
||||||
|
|
||||||
|
public VideoMetadataMatch Match { get; }
|
||||||
|
|
||||||
|
public string SourceName { get; }
|
||||||
|
|
||||||
|
public string Title { get; }
|
||||||
|
|
||||||
|
public string? Description { get; }
|
||||||
|
|
||||||
|
/// <summary>Comma-separated for display; the lists themselves stay on <see cref="Match"/>.</summary>
|
||||||
|
public string? Studios { get; }
|
||||||
|
|
||||||
|
public string? Performers { get; }
|
||||||
|
|
||||||
|
public string? Tags { get; }
|
||||||
|
|
||||||
|
public ReactiveCommand<RxVoid, RxVoid> ApplyCommand { get; }
|
||||||
|
|
||||||
|
private static string? Join(IReadOnlyList<string> values) =>
|
||||||
|
values.Count == 0 ? null : string.Join(", ", values);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using PLib.Application.Metadata;
|
||||||
|
using ReactiveUI;
|
||||||
|
using ReactiveUI.SourceGenerators;
|
||||||
|
using RxVoid = ReactiveUI.Primitives.RxVoid;
|
||||||
|
|
||||||
|
namespace PLib.Desktop.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>One metadata source being edited in the settings panel.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Unlike the folder rows, this one is editable in place: a source is three fields, and a
|
||||||
|
/// separate dialog to fill them in would be more ceremony than the thing deserves.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed partial class MetadataSourceEntryViewModel : ReactiveObject
|
||||||
|
{
|
||||||
|
public MetadataSourceEntryViewModel(
|
||||||
|
MetadataSourceOptions source,
|
||||||
|
Action<MetadataSourceEntryViewModel> remove)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
|
|
||||||
|
Name = source.Name;
|
||||||
|
Endpoint = source.Endpoint;
|
||||||
|
ApiKey = source.ApiKey;
|
||||||
|
IsEnabled = source.IsEnabled;
|
||||||
|
|
||||||
|
RemoveCommand = ReactiveCommand.Create(() => remove(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Reactive]
|
||||||
|
public partial string Name { get; set; }
|
||||||
|
|
||||||
|
[Reactive]
|
||||||
|
public partial string Endpoint { get; set; }
|
||||||
|
|
||||||
|
[Reactive]
|
||||||
|
public partial string ApiKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Lets a source be silenced without losing its endpoint and key.</summary>
|
||||||
|
[Reactive]
|
||||||
|
public partial bool IsEnabled { get; set; }
|
||||||
|
|
||||||
|
public ReactiveCommand<RxVoid, RxVoid> RemoveCommand { get; }
|
||||||
|
|
||||||
|
public MetadataSourceOptions ToOptions() => new()
|
||||||
|
{
|
||||||
|
Name = Name?.Trim() ?? string.Empty,
|
||||||
|
Endpoint = Endpoint?.Trim() ?? string.Empty,
|
||||||
|
ApiKey = ApiKey?.Trim() ?? string.Empty,
|
||||||
|
IsEnabled = IsEnabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ using System.Reactive.Subjects;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
|
using PLib.Application.Metadata;
|
||||||
using PLib.Desktop.Services;
|
using PLib.Desktop.Services;
|
||||||
using PLib.Desktop.Settings;
|
using PLib.Desktop.Settings;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
@@ -53,6 +54,7 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
|||||||
_original = settingsStore.Current;
|
_original = settingsStore.Current;
|
||||||
|
|
||||||
Folders = [.. _original.Folders.Select(CreateEntry)];
|
Folders = [.. _original.Folders.Select(CreateEntry)];
|
||||||
|
MetadataSources = [.. _original.MetadataSources.Select(CreateEntry)];
|
||||||
ThumbnailWidth = _original.ThumbnailWidth;
|
ThumbnailWidth = _original.ThumbnailWidth;
|
||||||
ThumbnailPositionPercent = ToPercent(_original.ThumbnailPositionRatio);
|
ThumbnailPositionPercent = ToPercent(_original.ThumbnailPositionRatio);
|
||||||
MaxIndexingConcurrency = _original.MaxIndexingConcurrency;
|
MaxIndexingConcurrency = _original.MaxIndexingConcurrency;
|
||||||
@@ -60,6 +62,7 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
|||||||
SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
|
SelectedTheme = ThemeOptions.First(option => option.Mode == _original.Theme);
|
||||||
|
|
||||||
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
|
AddFolderCommand = ReactiveCommand.CreateFromTask(AddFolderAsync);
|
||||||
|
AddMetadataSourceCommand = ReactiveCommand.Create(AddMetadataSource);
|
||||||
RefreshUsageCommand = ReactiveCommand.CreateFromTask(RefreshUsageAsync);
|
RefreshUsageCommand = ReactiveCommand.CreateFromTask(RefreshUsageAsync);
|
||||||
|
|
||||||
// One gate for every clearing button: they all talk to the same database and the same
|
// One gate for every clearing button: they all talk to the same database and the same
|
||||||
@@ -115,6 +118,10 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
|||||||
|
|
||||||
public bool HasFolders => Folders.Count > 0;
|
public bool HasFolders => Folders.Count > 0;
|
||||||
|
|
||||||
|
public ObservableCollection<MetadataSourceEntryViewModel> MetadataSources { get; }
|
||||||
|
|
||||||
|
public ReactiveCommand<RxVoid, RxVoid> AddMetadataSourceCommand { get; }
|
||||||
|
|
||||||
public IReadOnlyList<ThemeOption> ThemeOptions { get; } = ThemeOption.All;
|
public IReadOnlyList<ThemeOption> ThemeOptions { get; } = ThemeOption.All;
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> AddFolderCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> AddFolderCommand { get; }
|
||||||
@@ -178,6 +185,15 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
|||||||
: (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte),
|
: (long)Math.Round(MinimumFileSizeMegabytes * BytesPerMegabyte),
|
||||||
|
|
||||||
Theme = SelectedTheme.Mode,
|
Theme = SelectedTheme.Mode,
|
||||||
|
|
||||||
|
// Rows with nothing in them are what a half-finished edit looks like, and saving them
|
||||||
|
// would put empty entries in the file for the next opening to show again.
|
||||||
|
MetadataSources =
|
||||||
|
[
|
||||||
|
.. MetadataSources
|
||||||
|
.Select(entry => entry.ToOptions())
|
||||||
|
.Where(source => !string.IsNullOrWhiteSpace(source.Endpoint))
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
private static double ToPercent(double ratio) => Math.Round(ratio * 100);
|
private static double ToPercent(double ratio) => Math.Round(ratio * 100);
|
||||||
@@ -201,6 +217,12 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
|||||||
private FolderEntryViewModel CreateEntry(string path) =>
|
private FolderEntryViewModel CreateEntry(string path) =>
|
||||||
new(path, entry => Folders.Remove(entry));
|
new(path, entry => Folders.Remove(entry));
|
||||||
|
|
||||||
|
private MetadataSourceEntryViewModel CreateEntry(MetadataSourceOptions source) =>
|
||||||
|
new(source, entry => MetadataSources.Remove(entry));
|
||||||
|
|
||||||
|
private void AddMetadataSource() =>
|
||||||
|
MetadataSources.Add(CreateEntry(new MetadataSourceOptions()));
|
||||||
|
|
||||||
private async Task RefreshUsageAsync()
|
private async Task RefreshUsageAsync()
|
||||||
{
|
{
|
||||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
@@ -255,6 +277,7 @@ public sealed partial class SettingsViewModel : ViewModelBase
|
|||||||
.Merge<Exception>(
|
.Merge<Exception>(
|
||||||
[
|
[
|
||||||
AddFolderCommand.ThrownExceptions,
|
AddFolderCommand.ThrownExceptions,
|
||||||
|
AddMetadataSourceCommand.ThrownExceptions,
|
||||||
RefreshUsageCommand.ThrownExceptions,
|
RefreshUsageCommand.ThrownExceptions,
|
||||||
ClearAllCommand.ThrownExceptions,
|
ClearAllCommand.ThrownExceptions,
|
||||||
SaveCommand.ThrownExceptions,
|
SaveCommand.ThrownExceptions,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Reactive.Linq;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
|
using PLib.Application.Metadata;
|
||||||
using PLib.Desktop.Services;
|
using PLib.Desktop.Services;
|
||||||
using PLib.Domain.Videos;
|
using PLib.Domain.Videos;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
@@ -72,6 +73,12 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
|||||||
AddCollectionCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewCollection, LabelKind.Collection));
|
AddCollectionCommand = ReactiveCommand.CreateFromTask(() => AttachAsync(NewCollection, LabelKind.Collection));
|
||||||
LoadLabelsCommand = ReactiveCommand.CreateFromTask(LoadLabelsAsync);
|
LoadLabelsCommand = ReactiveCommand.CreateFromTask(LoadLabelsAsync);
|
||||||
|
|
||||||
|
// Only ever from this button: the whole point of the feature is that nothing talks to
|
||||||
|
// a remote service about the user's library on its own.
|
||||||
|
LookupMetadataCommand = ReactiveCommand.CreateFromTask(
|
||||||
|
LookupMetadataAsync,
|
||||||
|
this.WhenAnyValue(x => x.IsLookingUp).Select(busy => !busy));
|
||||||
|
|
||||||
this.WhenAnyValue(x => x.Volume, x => x.IsMuted, (volume, muted) => (volume, muted))
|
this.WhenAnyValue(x => x.Volume, x => x.IsMuted, (volume, muted) => (volume, muted))
|
||||||
// Skip the values we just restored: they are already what is on disk.
|
// Skip the values we just restored: they are already what is on disk.
|
||||||
.Skip(1)
|
.Skip(1)
|
||||||
@@ -88,7 +95,9 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
|||||||
|
|
||||||
public Guid VideoId { get; }
|
public Guid VideoId { get; }
|
||||||
|
|
||||||
public string Title { get; }
|
/// <summary>Reactive because applying a match renames the video under the open page.</summary>
|
||||||
|
[Reactive]
|
||||||
|
public partial string Title { get; set; }
|
||||||
|
|
||||||
public string FullPath { get; }
|
public string FullPath { get; }
|
||||||
|
|
||||||
@@ -107,6 +116,13 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
|||||||
|
|
||||||
public ObservableCollection<LabelViewModel> Collections { get; } = [];
|
public ObservableCollection<LabelViewModel> Collections { get; } = [];
|
||||||
|
|
||||||
|
public ObservableCollection<LabelViewModel> Performers { get; } = [];
|
||||||
|
|
||||||
|
public ObservableCollection<LabelViewModel> Studios { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Candidates from the last lookup; empty until the button is pressed.</summary>
|
||||||
|
public ObservableCollection<MetadataMatchViewModel> Matches { get; } = [];
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> CloseCommand { get; }
|
||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> ToggleFullScreenCommand { get; }
|
||||||
@@ -125,6 +141,19 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
|||||||
|
|
||||||
public ReactiveCommand<RxVoid, RxVoid> LoadLabelsCommand { get; }
|
public ReactiveCommand<RxVoid, RxVoid> LoadLabelsCommand { get; }
|
||||||
|
|
||||||
|
public ReactiveCommand<RxVoid, RxVoid> LookupMetadataCommand { get; }
|
||||||
|
|
||||||
|
[Reactive]
|
||||||
|
public partial bool IsLookingUp { get; set; }
|
||||||
|
|
||||||
|
/// <summary>What the last lookup came to, including which sources failed.</summary>
|
||||||
|
[Reactive]
|
||||||
|
public partial string? MetadataMessage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Free text about the video, once a match has supplied one.</summary>
|
||||||
|
[Reactive]
|
||||||
|
public partial string? Description { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True while the window is given over to the video. The page hides its own header and
|
/// True while the window is given over to the video. The page hides its own header and
|
||||||
/// the window hides its chrome.
|
/// the window hides its chrome.
|
||||||
@@ -227,17 +256,105 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
|||||||
|
|
||||||
Tags.Clear();
|
Tags.Clear();
|
||||||
Collections.Clear();
|
Collections.Clear();
|
||||||
|
Performers.Clear();
|
||||||
|
Studios.Clear();
|
||||||
|
|
||||||
var labels = video is null
|
if (video is null)
|
||||||
? []
|
{
|
||||||
: video.Labels.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase).ToArray();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var label in labels)
|
Title = video.Title;
|
||||||
|
Description = video.Description;
|
||||||
|
|
||||||
|
foreach (var label in video.Labels.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase))
|
||||||
{
|
{
|
||||||
Target(label.Kind).Add(new LabelViewModel(label, entry => _ = DetachAsync(entry)));
|
Target(label.Kind).Add(new LabelViewModel(label, entry => _ = DetachAsync(entry)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task LookupMetadataAsync()
|
||||||
|
{
|
||||||
|
IsLookingUp = true;
|
||||||
|
Matches.Clear();
|
||||||
|
|
||||||
|
// The results land in the side panel, so opening it is part of running the lookup —
|
||||||
|
// otherwise the button would appear to do nothing.
|
||||||
|
AreDetailsVisible = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
|
|
||||||
|
var result = await library.FindMetadataAsync(VideoId);
|
||||||
|
|
||||||
|
if (!result.HasPerceptualHash)
|
||||||
|
{
|
||||||
|
MetadataMessage = "Отпечаток ещё не посчитан — дождитесь окончания сканирования";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var match in result.Matches)
|
||||||
|
{
|
||||||
|
Matches.Add(new MetadataMatchViewModel(match, ApplyMetadataAsync));
|
||||||
|
}
|
||||||
|
|
||||||
|
MetadataMessage = Describe(result);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsLookingUp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Says what came back and what did not. A source that failed is reported even when the
|
||||||
|
/// others found something, because "one match" and "one match, and StashDB was down" call
|
||||||
|
/// for different next steps.
|
||||||
|
/// </summary>
|
||||||
|
private static string Describe(MetadataLookupResult result)
|
||||||
|
{
|
||||||
|
var found = result.Matches.Count == 0
|
||||||
|
? "Совпадений не найдено"
|
||||||
|
: $"Найдено совпадений: {result.Matches.Count}";
|
||||||
|
|
||||||
|
return result.Failures.Count == 0
|
||||||
|
? found
|
||||||
|
: $"{found}. Не ответили — {string.Join("; ", result.Failures)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handed to every match as its apply action. It swallows failures on purpose: the
|
||||||
|
/// commands live on the match rows, which come and go with each lookup, so there is no
|
||||||
|
/// stable place to observe their exceptions — and an unobserved one takes the process down.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ApplyMetadataAsync(VideoMetadataMatch match)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
|
var library = scope.ServiceProvider.GetRequiredService<ILibraryService>();
|
||||||
|
|
||||||
|
await library.ApplyMetadataAsync(VideoId, match);
|
||||||
|
await LoadLabelsAsync();
|
||||||
|
|
||||||
|
// The grid behind the page shows the old title until the card is told otherwise.
|
||||||
|
if (await library.GetVideoWithLabelsAsync(VideoId) is { } refreshed)
|
||||||
|
{
|
||||||
|
Card.Apply(refreshed);
|
||||||
|
}
|
||||||
|
|
||||||
|
Matches.Clear();
|
||||||
|
MetadataMessage = $"Применено: {match.SourceName}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Could not apply metadata from {Source}", match.SourceName);
|
||||||
|
MetadataMessage = "Не удалось применить — подробности в журнале";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task AttachAsync(string name, LabelKind kind)
|
private async Task AttachAsync(string name, LabelKind kind)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
@@ -278,8 +395,13 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ObservableCollection<LabelViewModel> Target(LabelKind kind) =>
|
private ObservableCollection<LabelViewModel> Target(LabelKind kind) => kind switch
|
||||||
kind == LabelKind.Tag ? Tags : Collections;
|
{
|
||||||
|
LabelKind.Collection => Collections,
|
||||||
|
LabelKind.Performer => Performers,
|
||||||
|
LabelKind.Studio => Studios,
|
||||||
|
_ => Tags,
|
||||||
|
};
|
||||||
|
|
||||||
private void Persist(double volume, bool isMuted) => _ = PersistAsync(volume, isMuted);
|
private void Persist(double volume, bool isMuted) => _ = PersistAsync(volume, isMuted);
|
||||||
|
|
||||||
@@ -307,7 +429,12 @@ public sealed partial class VideoPlayerViewModel : ViewModelBase
|
|||||||
RevealCommand.ThrownExceptions,
|
RevealCommand.ThrownExceptions,
|
||||||
AddTagCommand.ThrownExceptions,
|
AddTagCommand.ThrownExceptions,
|
||||||
AddCollectionCommand.ThrownExceptions,
|
AddCollectionCommand.ThrownExceptions,
|
||||||
LoadLabelsCommand.ThrownExceptions)
|
LoadLabelsCommand.ThrownExceptions,
|
||||||
.Subscribe(ex => _logger.LogError(ex, "A media page command failed"))
|
LookupMetadataCommand.ThrownExceptions)
|
||||||
|
.Subscribe(ex =>
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "A media page command failed");
|
||||||
|
MetadataMessage = "Что-то пошло не так — подробности в журнале";
|
||||||
|
})
|
||||||
.AddTo(Subscriptions);
|
.AddTo(Subscriptions);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,57 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Metadata sources -->
|
||||||
|
<Border Classes="section">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Classes="sectionTitle" FontSize="14" Text="Источники метаданных" />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding MetadataSources}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:MetadataSourceEntryViewModel">
|
||||||
|
<Border Background="{DynamicResource CardBackgroundBrush}"
|
||||||
|
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="10,8"
|
||||||
|
Margin="0,0,0,8">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="8">
|
||||||
|
<CheckBox Grid.Column="0"
|
||||||
|
IsChecked="{Binding IsEnabled}"
|
||||||
|
ToolTip.Tip="Опрашивать этот источник" />
|
||||||
|
<TextBox Grid.Column="1"
|
||||||
|
PlaceholderText="Название"
|
||||||
|
Text="{Binding Name}" />
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Command="{Binding RemoveCommand}"
|
||||||
|
Padding="6"
|
||||||
|
ToolTip.Tip="Убрать источник">
|
||||||
|
<icons:MaterialIcon Kind="Close" Width="14" Height="14" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<TextBox PlaceholderText="https://…/graphql" Text="{Binding Endpoint}" />
|
||||||
|
<TextBox PlaceholderText="API-ключ"
|
||||||
|
PasswordChar="•"
|
||||||
|
Text="{Binding ApiKey}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<Button HorizontalAlignment="Left" Command="{Binding AddMetadataSourceCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||||
|
<icons:MaterialIcon Kind="DatabasePlusOutline" Width="16" Height="16" />
|
||||||
|
<TextBlock Text="Добавить источник" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<TextBlock Classes="hint"
|
||||||
|
Text="GraphQL-эндпойнты со схемой stash-box (StashDB и подобные). Запрос идёт только по кнопке «Найти метаданные» на странице видео и только по отпечатку — сами файлы никуда не отправляются. Ключи хранятся в settings.json открытым текстом." />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Scanning -->
|
<!-- Scanning -->
|
||||||
<Border Classes="section">
|
<Border Classes="section">
|
||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
|
|||||||
@@ -72,6 +72,11 @@
|
|||||||
ToolTip.Tip="Сведения и метки">
|
ToolTip.Tip="Сведения и метки">
|
||||||
<icons:MaterialIcon Kind="InformationOutline" Width="17" Height="17" />
|
<icons:MaterialIcon Kind="InformationOutline" Width="17" Height="17" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button Classes="transport"
|
||||||
|
Command="{Binding LookupMetadataCommand}"
|
||||||
|
ToolTip.Tip="Найти метаданные по отпечатку (pHash)">
|
||||||
|
<icons:MaterialIcon Kind="DatabaseSearchOutline" Width="17" Height="17" />
|
||||||
|
</Button>
|
||||||
<Button Classes="transport"
|
<Button Classes="transport"
|
||||||
Command="{Binding OpenExternallyCommand}"
|
Command="{Binding OpenExternallyCommand}"
|
||||||
ToolTip.Tip="Открыть во внешнем плеере">
|
ToolTip.Tip="Открыть во внешнем плеере">
|
||||||
@@ -141,6 +146,89 @@
|
|||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Metadata lookup: nothing here until the button in the header is pressed. -->
|
||||||
|
<StackPanel Spacing="8"
|
||||||
|
IsVisible="{Binding MetadataMessage, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
|
<TextBlock Classes="panelTitle" Text="Метаданные" />
|
||||||
|
<TextBlock Classes="cardMeta" TextWrapping="Wrap" Text="{Binding MetadataMessage}" />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Matches}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:MetadataMatchViewModel">
|
||||||
|
<Border Background="{DynamicResource CardBackgroundBrush}"
|
||||||
|
BorderBrush="{DynamicResource CardBorderBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="10,8"
|
||||||
|
Margin="0,0,0,8">
|
||||||
|
<StackPanel Spacing="5">
|
||||||
|
<TextBlock Text="{Binding Title}"
|
||||||
|
FontSize="12.5"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||||
|
<TextBlock Classes="cardMeta" Text="{Binding SourceName}" />
|
||||||
|
|
||||||
|
<TextBlock Classes="cardMeta"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="{Binding Studios, StringFormat='Студия: {0}'}"
|
||||||
|
IsVisible="{Binding Studios, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
|
<TextBlock Classes="cardMeta"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="{Binding Performers, StringFormat='Актёры: {0}'}"
|
||||||
|
IsVisible="{Binding Performers, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
|
<TextBlock Classes="cardMeta"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Text="{Binding Tags, StringFormat='Теги: {0}'}"
|
||||||
|
IsVisible="{Binding Tags, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
|
<TextBlock Classes="cardMeta"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
MaxLines="4"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
Text="{Binding Description}"
|
||||||
|
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
|
|
||||||
|
<Button HorizontalAlignment="Left"
|
||||||
|
Command="{Binding ApplyCommand}"
|
||||||
|
Content="Применить" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="8"
|
||||||
|
IsVisible="{Binding Description, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
|
<TextBlock Classes="panelTitle" Text="Описание" />
|
||||||
|
<TextBlock Text="{Binding Description}"
|
||||||
|
FontSize="12"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="8" IsVisible="{Binding Performers.Count}">
|
||||||
|
<TextBlock Classes="panelTitle" Text="Актёры" />
|
||||||
|
<ItemsControl ItemsSource="{Binding Performers}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="8" IsVisible="{Binding Studios.Count}">
|
||||||
|
<TextBlock Classes="panelTitle" Text="Студии" />
|
||||||
|
<ItemsControl ItemsSource="{Binding Studios}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
<TextBlock Classes="panelTitle" Text="Теги" />
|
<TextBlock Classes="panelTitle" Text="Теги" />
|
||||||
<ItemsControl ItemsSource="{Binding Tags}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
<ItemsControl ItemsSource="{Binding Tags}" ItemTemplate="{StaticResource LabelChipTemplate}">
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
"MaxIndexingConcurrency": 4,
|
"MaxIndexingConcurrency": 4,
|
||||||
"MinimumFileSizeInBytes": 65536
|
"MinimumFileSizeInBytes": 65536
|
||||||
},
|
},
|
||||||
|
"Metadata": {
|
||||||
|
"Sources": []
|
||||||
|
},
|
||||||
"Appearance": {
|
"Appearance": {
|
||||||
"Theme": "Dark"
|
"Theme": "Dark"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,17 +8,23 @@ public enum LabelKind
|
|||||||
|
|
||||||
/// <summary>A named group the user curates and browses as a whole.</summary>
|
/// <summary>A named group the user curates and browses as a whole.</summary>
|
||||||
Collection,
|
Collection,
|
||||||
|
|
||||||
|
/// <summary>Someone who appears in the video.</summary>
|
||||||
|
Performer,
|
||||||
|
|
||||||
|
/// <summary>Who produced it.</summary>
|
||||||
|
Studio,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A named grouping of videos — a tag or a collection.
|
/// A named grouping of videos — a tag or a collection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Tags and collections are the same relation: a name, many videos, a video in many of them.
|
/// Tags, collections, performers and studios are the same relation: a name, many videos, a
|
||||||
/// They differ only in intent, and that intent is <see cref="Kind"/>. One entity means one
|
/// video in many of them. They differ only in intent, and that intent is <see cref="Kind"/>.
|
||||||
/// join table, one repository and one set of rules about naming; splitting them later is a
|
/// One entity means one join table, one repository and one set of rules about naming;
|
||||||
/// rename and a migration, whereas keeping two near-identical aggregates in sync from the
|
/// splitting them later is a rename and a migration, whereas keeping four near-identical
|
||||||
/// start is a permanent tax.
|
/// aggregates in sync from the start is a permanent tax.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class LibraryLabel
|
public sealed class LibraryLabel
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ public sealed class VideoItem
|
|||||||
/// <summary>Human readable name; defaults to the file name without extension.</summary>
|
/// <summary>Human readable name; defaults to the file name without extension.</summary>
|
||||||
public string Title { get; private set; }
|
public string Title { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Free text about the video. Never derived from the file — it only arrives from a
|
||||||
|
/// metadata source or from the user, which is why refreshing the file leaves it alone.
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; private set; }
|
||||||
|
|
||||||
public long SizeInBytes { get; private set; }
|
public long SizeInBytes { get; private set; }
|
||||||
|
|
||||||
public TimeSpan? Duration { get; private set; }
|
public TimeSpan? Duration { get; private set; }
|
||||||
@@ -133,6 +139,10 @@ public sealed class VideoItem
|
|||||||
Title = title;
|
Title = title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Replaces the description; blank clears it rather than storing whitespace.</summary>
|
||||||
|
public void Describe(string? description) =>
|
||||||
|
Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
|
||||||
|
|
||||||
public void ApplyTechnicalInfo(VideoTechnicalInfo info)
|
public void ApplyTechnicalInfo(VideoTechnicalInfo info)
|
||||||
{
|
{
|
||||||
Duration = info.Duration;
|
Duration = info.Duration;
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using PLib.Application.Abstractions;
|
using PLib.Application.Abstractions;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
|
using PLib.Application.Metadata;
|
||||||
using PLib.Infrastructure.Media;
|
using PLib.Infrastructure.Media;
|
||||||
|
using PLib.Infrastructure.Metadata;
|
||||||
using PLib.Infrastructure.Persistence;
|
using PLib.Infrastructure.Persistence;
|
||||||
using PLib.Infrastructure.Storage;
|
using PLib.Infrastructure.Storage;
|
||||||
|
|
||||||
@@ -25,6 +27,12 @@ public static class DependencyInjection
|
|||||||
.ValidateDataAnnotations()
|
.ValidateDataAnnotations()
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
// Deliberately not validated on start: a half-filled source is something the user is
|
||||||
|
// in the middle of typing, and refusing to launch over it would be absurd. Whether an
|
||||||
|
// entry is worth calling is decided when it is called.
|
||||||
|
services.AddOptions<MetadataOptions>()
|
||||||
|
.Bind(configuration.GetSection(MetadataOptions.SectionName));
|
||||||
|
|
||||||
// TryAdd so a composition root that already needed the paths (to locate the user
|
// TryAdd so a composition root that already needed the paths (to locate the user
|
||||||
// settings file before the container exists) can share its own instance.
|
// settings file before the container exists) can share its own instance.
|
||||||
services.TryAddSingleton<IAppPaths, AppPaths>();
|
services.TryAddSingleton<IAppPaths, AppPaths>();
|
||||||
@@ -43,6 +51,16 @@ public static class DependencyInjection
|
|||||||
services.AddSingleton<IThumbnailGenerator, FfmpegThumbnailGenerator>();
|
services.AddSingleton<IThumbnailGenerator, FfmpegThumbnailGenerator>();
|
||||||
services.AddSingleton<IAnimatedPreviewGenerator, FfmpegAnimatedPreviewGenerator>();
|
services.AddSingleton<IAnimatedPreviewGenerator, FfmpegAnimatedPreviewGenerator>();
|
||||||
services.AddSingleton<IVideoPerceptualHasher, FfmpegVideoPerceptualHasher>();
|
services.AddSingleton<IVideoPerceptualHasher, FfmpegVideoPerceptualHasher>();
|
||||||
|
|
||||||
|
// A short timeout on purpose: this runs behind a button the user is waiting on, and a
|
||||||
|
// source that has not answered in fifteen seconds is better reported than waited for.
|
||||||
|
services.AddHttpClient(StashBoxMetadataProvider.HttpClientName, client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(15);
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd("PLib/1.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddSingleton<IMetadataProvider, StashBoxMetadataProvider>();
|
||||||
services.AddScoped<ILibraryService, LibraryService>();
|
services.AddScoped<ILibraryService, LibraryService>();
|
||||||
|
|
||||||
services.AddHostedService<DatabaseInitializer>();
|
services.AddHostedService<DatabaseInitializer>();
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PLib.Application.Abstractions;
|
||||||
|
using PLib.Application.Metadata;
|
||||||
|
|
||||||
|
namespace PLib.Infrastructure.Metadata;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks a video up by perceptual hash against a stash-box GraphQL endpoint.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// stash-box is the schema the configurable-endpoint-plus-key arrangement exists for: StashDB
|
||||||
|
/// and its siblings are separate instances of one server, each with its own address and its
|
||||||
|
/// own key, and all of them answer the same query. That is what makes a list of sources
|
||||||
|
/// meaningful — a list of endpoints speaking unrelated schemas could not share one query.
|
||||||
|
///
|
||||||
|
/// No GraphQL client library: the whole conversation is one POST of <c>{query, variables}</c>
|
||||||
|
/// and one object to read out of the reply, and a dependency to build that string would be
|
||||||
|
/// larger than the code it replaced.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class StashBoxMetadataProvider(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ILogger<StashBoxMetadataProvider> logger) : IMetadataProvider
|
||||||
|
{
|
||||||
|
/// <summary>Name of the configured <see cref="HttpClient"/>; see the DI registration.</summary>
|
||||||
|
public const string HttpClientName = "metadata";
|
||||||
|
|
||||||
|
private const string Query = """
|
||||||
|
query FindSceneByFingerprint($hash: String!) {
|
||||||
|
findSceneByFingerprint(fingerprint: { hash: $hash, algorithm: PHASH }) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
details
|
||||||
|
studio { name }
|
||||||
|
tags { name }
|
||||||
|
performers { performer { name } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<VideoMetadataMatch>> FindByPerceptualHashAsync(
|
||||||
|
MetadataSourceOptions source,
|
||||||
|
ulong perceptualHash,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, source.Endpoint)
|
||||||
|
{
|
||||||
|
// stash and stash-box both hash to a 16-digit lower-case hex string, so the
|
||||||
|
// fingerprint travels in the form the far end already stores it in.
|
||||||
|
Content = JsonContent.Create(new
|
||||||
|
{
|
||||||
|
query = Query,
|
||||||
|
variables = new { hash = perceptualHash.ToString("x16", CultureInfo.InvariantCulture) },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(source.ApiKey))
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("ApiKey", source.ApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = httpClientFactory.CreateClient(HttpClientName);
|
||||||
|
|
||||||
|
using var response = await client.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"{(int)response.StatusCode} {response.ReasonPhrase}",
|
||||||
|
inner: null,
|
||||||
|
response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
ThrowOnGraphQlErrors(document.RootElement, source.Name);
|
||||||
|
|
||||||
|
if (!document.RootElement.TryGetProperty("data", out var data) ||
|
||||||
|
!data.TryGetProperty("findSceneByFingerprint", out var scenes) ||
|
||||||
|
scenes.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
logger.LogDebug("Source {Source} returned no scenes element", source.Name);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. scenes.EnumerateArray().Select(scene => ReadScene(scene, source.Name))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A GraphQL server answers 200 with an <c>errors</c> array, so a bad key or a schema
|
||||||
|
/// mismatch would otherwise read as "this source knows nothing about your video".
|
||||||
|
/// </summary>
|
||||||
|
private static void ThrowOnGraphQlErrors(JsonElement root, string sourceName)
|
||||||
|
{
|
||||||
|
if (!root.TryGetProperty("errors", out var errors) ||
|
||||||
|
errors.ValueKind != JsonValueKind.Array ||
|
||||||
|
errors.GetArrayLength() == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages = errors
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(error => error.TryGetProperty("message", out var message)
|
||||||
|
? message.GetString()
|
||||||
|
: null)
|
||||||
|
.Where(message => !string.IsNullOrWhiteSpace(message));
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"{sourceName}: {string.Join("; ", messages)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VideoMetadataMatch ReadScene(JsonElement scene, string sourceName) => new(
|
||||||
|
sourceName,
|
||||||
|
Text(scene, "id"),
|
||||||
|
Text(scene, "title") ?? "Без названия",
|
||||||
|
Text(scene, "details"),
|
||||||
|
Names(scene, "tags"),
|
||||||
|
Performers(scene),
|
||||||
|
Studios(scene));
|
||||||
|
|
||||||
|
private static string? Text(JsonElement element, string property) =>
|
||||||
|
element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
|
||||||
|
? value.GetString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> Names(JsonElement scene, string property)
|
||||||
|
{
|
||||||
|
if (!scene.TryGetProperty(property, out var array) || array.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. array.EnumerateArray().Select(item => Text(item, "name")).OfType<string>()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performers arrive wrapped in an appearance — the same person can be credited under a
|
||||||
|
/// different name on a given scene — and it is the person's name we want.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<string> Performers(JsonElement scene)
|
||||||
|
{
|
||||||
|
if (!scene.TryGetProperty("performers", out var array) || array.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
.. array
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(appearance => appearance.TryGetProperty("performer", out var performer)
|
||||||
|
? Text(performer, "name")
|
||||||
|
: null)
|
||||||
|
.OfType<string>()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A scene has at most one studio; the shape is a list because a match may not.</summary>
|
||||||
|
private static IReadOnlyList<string> Studios(JsonElement scene) =>
|
||||||
|
scene.TryGetProperty("studio", out var studio) && Text(studio, "name") is { } name
|
||||||
|
? [name]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
// <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("20260809051032_SceneMetadata")]
|
||||||
|
partial class SceneMetadata
|
||||||
|
{
|
||||||
|
/// <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>("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();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PLib.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SceneMetadata : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Description",
|
||||||
|
table: "Videos",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Description",
|
||||||
|
table: "Videos");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,9 @@ namespace PLib.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<long>("AddedAt")
|
b.Property<long>("AddedAt")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<TimeSpan?>("Duration")
|
b.Property<TimeSpan?>("Duration")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ internal sealed class VideoItemConfiguration : IEntityTypeConfiguration<VideoIte
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(512);
|
.HasMaxLength(512);
|
||||||
|
|
||||||
|
// Unbounded on purpose: a synopsis from a metadata source has no natural ceiling, and
|
||||||
|
// truncating one silently is worse than storing it whole.
|
||||||
|
builder.Property(x => x.Description);
|
||||||
|
|
||||||
builder.Property(x => x.VideoCodec)
|
builder.Property(x => x.VideoCodec)
|
||||||
.HasMaxLength(64);
|
.HasMaxLength(64);
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ public sealed class DuplicateDetectionTests
|
|||||||
Substitute.For<IThumbnailGenerator>(),
|
Substitute.For<IThumbnailGenerator>(),
|
||||||
Substitute.For<IAnimatedPreviewGenerator>(),
|
Substitute.For<IAnimatedPreviewGenerator>(),
|
||||||
Substitute.For<IVideoPerceptualHasher>(),
|
Substitute.For<IVideoPerceptualHasher>(),
|
||||||
|
Substitute.For<IMetadataProvider>(),
|
||||||
Options.Create(new LibraryOptions()),
|
Options.Create(new LibraryOptions()),
|
||||||
|
MetadataMonitor.Empty,
|
||||||
NullLogger<LibraryService>.Instance);
|
NullLogger<LibraryService>.Instance);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ public sealed class LabelTests
|
|||||||
Substitute.For<IThumbnailGenerator>(),
|
Substitute.For<IThumbnailGenerator>(),
|
||||||
Substitute.For<IAnimatedPreviewGenerator>(),
|
Substitute.For<IAnimatedPreviewGenerator>(),
|
||||||
Substitute.For<IVideoPerceptualHasher>(),
|
Substitute.For<IVideoPerceptualHasher>(),
|
||||||
|
Substitute.For<IMetadataProvider>(),
|
||||||
Options.Create(new LibraryOptions()),
|
Options.Create(new LibraryOptions()),
|
||||||
|
MetadataMonitor.Empty,
|
||||||
NullLogger<LibraryService>.Instance);
|
NullLogger<LibraryService>.Instance);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Options;
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using PLib.Application.Abstractions;
|
using PLib.Application.Abstractions;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
|
using PLib.Application.Metadata;
|
||||||
using PLib.Domain.Videos;
|
using PLib.Domain.Videos;
|
||||||
using Shouldly;
|
using Shouldly;
|
||||||
|
|
||||||
@@ -13,11 +14,13 @@ public sealed class LibraryServiceTests
|
|||||||
private const string Root = @"C:\videos";
|
private const string Root = @"C:\videos";
|
||||||
|
|
||||||
private readonly InMemoryVideoRepository _repository = new();
|
private readonly InMemoryVideoRepository _repository = new();
|
||||||
|
private readonly InMemoryLabelRepository _labels = new();
|
||||||
private readonly IVideoFileScanner _scanner = Substitute.For<IVideoFileScanner>();
|
private readonly IVideoFileScanner _scanner = Substitute.For<IVideoFileScanner>();
|
||||||
private readonly IMediaProbe _probe = Substitute.For<IMediaProbe>();
|
private readonly IMediaProbe _probe = Substitute.For<IMediaProbe>();
|
||||||
private readonly IThumbnailGenerator _thumbnails = Substitute.For<IThumbnailGenerator>();
|
private readonly IThumbnailGenerator _thumbnails = Substitute.For<IThumbnailGenerator>();
|
||||||
private readonly IAnimatedPreviewGenerator _previews = Substitute.For<IAnimatedPreviewGenerator>();
|
private readonly IAnimatedPreviewGenerator _previews = Substitute.For<IAnimatedPreviewGenerator>();
|
||||||
private readonly IVideoPerceptualHasher _hasher = Substitute.For<IVideoPerceptualHasher>();
|
private readonly IVideoPerceptualHasher _hasher = Substitute.For<IVideoPerceptualHasher>();
|
||||||
|
private readonly IMetadataProvider _metadata = Substitute.For<IMetadataProvider>();
|
||||||
|
|
||||||
public LibraryServiceTests()
|
public LibraryServiceTests()
|
||||||
{
|
{
|
||||||
@@ -298,6 +301,107 @@ public sealed class LibraryServiceTests
|
|||||||
usage[LibraryDataKind.PerceptualHashes].Bytes.ShouldBe(0);
|
usage[LibraryDataKind.PerceptualHashes].Bytes.ShouldBe(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_video_without_a_fingerprint_cannot_be_looked_up()
|
||||||
|
{
|
||||||
|
var item = new VideoItem(@"C:\videos\a.mp4", "a", 5_000, DateTimeOffset.UnixEpoch);
|
||||||
|
_repository.Seed(item);
|
||||||
|
|
||||||
|
var result = await CreateService(sources: MetadataMonitor.With(Source("StashDB")))
|
||||||
|
.FindMetadataAsync(item.Id, Token);
|
||||||
|
|
||||||
|
// The answer is "wait for the scan", not "check your sources", so no source is called.
|
||||||
|
result.HasPerceptualHash.ShouldBeFalse();
|
||||||
|
await _metadata.DidNotReceive().FindByPerceptualHashAsync(
|
||||||
|
Arg.Any<MetadataSourceOptions>(),
|
||||||
|
Arg.Any<ulong>(),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_source_that_fails_does_not_hide_what_the_others_found()
|
||||||
|
{
|
||||||
|
var item = FullyIndexed();
|
||||||
|
_repository.Seed(item);
|
||||||
|
|
||||||
|
var good = Source("Хороший");
|
||||||
|
var bad = Source("Сломанный");
|
||||||
|
|
||||||
|
_metadata.FindByPerceptualHashAsync(good, Arg.Any<ulong>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns([Match("Сцена", good.Name)]);
|
||||||
|
_metadata.FindByPerceptualHashAsync(bad, Arg.Any<ulong>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns<IReadOnlyList<VideoMetadataMatch>>(_ => throw new HttpRequestException("401"));
|
||||||
|
|
||||||
|
var result = await CreateService(sources: MetadataMonitor.With(bad, good)).FindMetadataAsync(item.Id, Token);
|
||||||
|
|
||||||
|
result.Matches.Single().Title.ShouldBe("Сцена");
|
||||||
|
result.Failures.ShouldHaveSingleItem().ShouldContain("Сломанный");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sources_that_are_switched_off_or_half_filled_are_not_called()
|
||||||
|
{
|
||||||
|
var item = FullyIndexed();
|
||||||
|
_repository.Seed(item);
|
||||||
|
|
||||||
|
var disabled = new MetadataSourceOptions { Name = "Выключен", Endpoint = "https://a/graphql", IsEnabled = false };
|
||||||
|
var blank = new MetadataSourceOptions { Name = "Недописан", Endpoint = " " };
|
||||||
|
|
||||||
|
await CreateService(sources: MetadataMonitor.With(disabled, blank)).FindMetadataAsync(item.Id, Token);
|
||||||
|
|
||||||
|
await _metadata.DidNotReceive().FindByPerceptualHashAsync(
|
||||||
|
Arg.Any<MetadataSourceOptions>(),
|
||||||
|
Arg.Any<ulong>(),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Applying_a_match_writes_the_text_and_a_label_of_the_right_kind_for_each_name()
|
||||||
|
{
|
||||||
|
var item = FullyIndexed();
|
||||||
|
_repository.Seed(item);
|
||||||
|
|
||||||
|
var match = new VideoMetadataMatch(
|
||||||
|
"StashDB",
|
||||||
|
"scene-1",
|
||||||
|
"Настоящее название",
|
||||||
|
"Описание",
|
||||||
|
Tags: ["Драма", "драма"],
|
||||||
|
Performers: ["Актёр Один"],
|
||||||
|
Studios: ["Студия"]);
|
||||||
|
|
||||||
|
await CreateService().ApplyMetadataAsync(item.Id, match, Token);
|
||||||
|
|
||||||
|
item.Title.ShouldBe("Настоящее название");
|
||||||
|
item.Description.ShouldBe("Описание");
|
||||||
|
|
||||||
|
// The two spellings of the tag are one label: names are matched case-insensitively.
|
||||||
|
item.Labels.Count(label => label.Kind == LabelKind.Tag).ShouldBe(1);
|
||||||
|
item.Labels.Single(label => label.Kind == LabelKind.Performer).Name.ShouldBe("Актёр Один");
|
||||||
|
item.Labels.Single(label => label.Kind == LabelKind.Studio).Name.ShouldBe("Студия");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Applying_a_match_adds_to_the_labels_already_on_the_video()
|
||||||
|
{
|
||||||
|
var item = FullyIndexed();
|
||||||
|
_repository.Seed(item);
|
||||||
|
|
||||||
|
var service = CreateService();
|
||||||
|
await service.AttachLabelAsync(item.Id, "Моё", LabelKind.Tag, Token);
|
||||||
|
|
||||||
|
await service.ApplyMetadataAsync(item.Id, Match("Название", "StashDB") with { Tags = ["Их"] }, Token);
|
||||||
|
|
||||||
|
// A match is a proposal, not a replacement: what the user put there stays.
|
||||||
|
item.Labels.Select(label => label.Name).ShouldBe(["Моё", "Их"], ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MetadataSourceOptions Source(string name) =>
|
||||||
|
new() { Name = name, Endpoint = $"https://{name}.example/graphql", ApiKey = "key" };
|
||||||
|
|
||||||
|
private static VideoMetadataMatch Match(string title, string sourceName) =>
|
||||||
|
new(sourceName, "id", title, null, [], [], []);
|
||||||
|
|
||||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
private static VideoItem FullyIndexed()
|
private static VideoItem FullyIndexed()
|
||||||
@@ -317,15 +421,19 @@ public sealed class LibraryServiceTests
|
|||||||
_scanner.ScanAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
_scanner.ScanAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(_ => files.ToAsyncEnumerable());
|
.Returns(_ => files.ToAsyncEnumerable());
|
||||||
|
|
||||||
private LibraryService CreateService(LibraryOptions? options = null) => new(
|
private LibraryService CreateService(
|
||||||
|
LibraryOptions? options = null,
|
||||||
|
MetadataMonitor? sources = null) => new(
|
||||||
_repository,
|
_repository,
|
||||||
new InMemoryLabelRepository(),
|
_labels,
|
||||||
_scanner,
|
_scanner,
|
||||||
_probe,
|
_probe,
|
||||||
_thumbnails,
|
_thumbnails,
|
||||||
_previews,
|
_previews,
|
||||||
_hasher,
|
_hasher,
|
||||||
|
_metadata,
|
||||||
Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
|
Options.Create(options ?? new LibraryOptions { MinimumFileSizeInBytes = 0 }),
|
||||||
|
sources ?? MetadataMonitor.Empty,
|
||||||
NullLogger<LibraryService>.Instance);
|
NullLogger<LibraryService>.Instance);
|
||||||
|
|
||||||
private static async Task<List<LibraryScanEvent>> CollectAsync(
|
private static async Task<List<LibraryScanEvent>> CollectAsync(
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using PLib.Application.Metadata;
|
||||||
|
|
||||||
|
namespace PLib.Tests.Library;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A fixed <see cref="IOptionsMonitor{T}"/> over metadata sources.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The service reads <c>CurrentValue</c> on every lookup, because the user can add a source
|
||||||
|
/// without restarting. A substitute would answer null and blow up in the one place that
|
||||||
|
/// matters, so the tests hand it a real value instead.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class MetadataMonitor(MetadataOptions value) : IOptionsMonitor<MetadataOptions>
|
||||||
|
{
|
||||||
|
public static MetadataMonitor Empty { get; } = new(new MetadataOptions());
|
||||||
|
|
||||||
|
public static MetadataMonitor With(params MetadataSourceOptions[] sources) =>
|
||||||
|
new(new MetadataOptions { Sources = [.. sources] });
|
||||||
|
|
||||||
|
public MetadataOptions CurrentValue { get; } = value;
|
||||||
|
|
||||||
|
public MetadataOptions Get(string? name) => CurrentValue;
|
||||||
|
|
||||||
|
public IDisposable? OnChange(Action<MetadataOptions, string?> listener) => null;
|
||||||
|
}
|
||||||
@@ -1,166 +1,220 @@
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
using PLib.Desktop.Services;
|
using PLib.Application.Metadata;
|
||||||
using PLib.Desktop.Settings;
|
using PLib.Desktop.Services;
|
||||||
using PLib.Infrastructure.Storage;
|
using PLib.Desktop.Settings;
|
||||||
using Shouldly;
|
using PLib.Infrastructure.Storage;
|
||||||
|
using Shouldly;
|
||||||
namespace PLib.Tests.Settings;
|
|
||||||
|
namespace PLib.Tests.Settings;
|
||||||
/// <summary>
|
|
||||||
/// The settings file is not just storage — it is a live configuration source. These tests
|
/// <summary>
|
||||||
/// close the loop: what the store writes has to be what the options binder reads back.
|
/// The settings file is not just storage — it is a live configuration source. These tests
|
||||||
/// </summary>
|
/// close the loop: what the store writes has to be what the options binder reads back.
|
||||||
public sealed class AppSettingsStoreTests : IDisposable
|
/// </summary>
|
||||||
{
|
public sealed class AppSettingsStoreTests : IDisposable
|
||||||
private readonly TempPaths _paths = new();
|
{
|
||||||
|
private readonly TempPaths _paths = new();
|
||||||
public void Dispose() => _paths.Dispose();
|
|
||||||
|
public void Dispose() => _paths.Dispose();
|
||||||
[Fact]
|
|
||||||
public async Task Saved_settings_are_readable_by_the_configuration_binder()
|
[Fact]
|
||||||
{
|
public async Task Saved_settings_are_readable_by_the_configuration_binder()
|
||||||
var settings = new AppSettings
|
{
|
||||||
{
|
var settings = new AppSettings
|
||||||
Folders = [@"C:\videos", @"D:\more videos"],
|
{
|
||||||
ThumbnailWidth = 640,
|
Folders = [@"C:\videos", @"D:\more videos"],
|
||||||
ThumbnailPositionRatio = 0.25,
|
ThumbnailWidth = 640,
|
||||||
MaxIndexingConcurrency = 8,
|
ThumbnailPositionRatio = 0.25,
|
||||||
MinimumFileSizeInBytes = 2_097_152,
|
MaxIndexingConcurrency = 8,
|
||||||
Theme = ThemeMode.Light,
|
MinimumFileSizeInBytes = 2_097_152,
|
||||||
Volume = 0.35,
|
Theme = ThemeMode.Light,
|
||||||
IsMuted = true,
|
Volume = 0.35,
|
||||||
};
|
IsMuted = true,
|
||||||
|
MetadataSources = [],
|
||||||
await CreateStore().SaveAsync(settings, Token);
|
};
|
||||||
|
|
||||||
var (library, appearance, playback) = Reload();
|
await CreateStore().SaveAsync(settings, Token);
|
||||||
|
|
||||||
library.Folders.ShouldBe(settings.Folders);
|
var (library, appearance, playback) = Reload();
|
||||||
library.ThumbnailWidth.ShouldBe(640);
|
|
||||||
library.ThumbnailPositionRatio.ShouldBe(0.25);
|
library.Folders.ShouldBe(settings.Folders);
|
||||||
library.MaxIndexingConcurrency.ShouldBe(8);
|
library.ThumbnailWidth.ShouldBe(640);
|
||||||
library.MinimumFileSizeInBytes.ShouldBe(2_097_152);
|
library.ThumbnailPositionRatio.ShouldBe(0.25);
|
||||||
appearance.Theme.ShouldBe(ThemeMode.Light);
|
library.MaxIndexingConcurrency.ShouldBe(8);
|
||||||
playback.Volume.ShouldBe(0.35);
|
library.MinimumFileSizeInBytes.ShouldBe(2_097_152);
|
||||||
playback.IsMuted.ShouldBeTrue();
|
appearance.Theme.ShouldBe(ThemeMode.Light);
|
||||||
}
|
playback.Volume.ShouldBe(0.35);
|
||||||
|
playback.IsMuted.ShouldBeTrue();
|
||||||
[Fact]
|
}
|
||||||
public async Task Removing_a_folder_actually_shortens_the_stored_list()
|
|
||||||
{
|
[Fact]
|
||||||
var store = CreateStore();
|
public async Task Removing_a_folder_actually_shortens_the_stored_list()
|
||||||
var settings = Sample with { Folders = [@"C:\a", @"C:\b", @"C:\c"] };
|
{
|
||||||
|
var store = CreateStore();
|
||||||
await store.SaveAsync(settings, Token);
|
var settings = Sample with { Folders = [@"C:\a", @"C:\b", @"C:\c"] };
|
||||||
await store.SaveAsync(settings with { Folders = [@"C:\a", @"C:\c"] }, Token);
|
|
||||||
|
await store.SaveAsync(settings, Token);
|
||||||
// Configuration merges arrays by index, so a shorter list is the case most likely
|
await store.SaveAsync(settings with { Folders = [@"C:\a", @"C:\c"] }, Token);
|
||||||
// to leave a stale entry behind.
|
|
||||||
Reload().Library.Folders.ShouldBe([@"C:\a", @"C:\c"]);
|
// Configuration merges arrays by index, so a shorter list is the case most likely
|
||||||
}
|
// to leave a stale entry behind.
|
||||||
|
Reload().Library.Folders.ShouldBe([@"C:\a", @"C:\c"]);
|
||||||
[Fact]
|
}
|
||||||
public async Task Keys_the_settings_screen_does_not_know_about_survive_a_save()
|
|
||||||
{
|
[Fact]
|
||||||
await File.WriteAllTextAsync(
|
public async Task Keys_the_settings_screen_does_not_know_about_survive_a_save()
|
||||||
Path.Combine(_paths.DataDirectory, "settings.json"),
|
{
|
||||||
"""{ "Library": { "VideoExtensions": [ ".mp4" ] }, "Experimental": { "Flag": true } }""",
|
await File.WriteAllTextAsync(
|
||||||
Token);
|
Path.Combine(_paths.DataDirectory, "settings.json"),
|
||||||
|
"""{ "Library": { "VideoExtensions": [ ".mp4" ] }, "Experimental": { "Flag": true } }""",
|
||||||
await CreateStore().SaveAsync(Sample, Token);
|
Token);
|
||||||
|
|
||||||
var configuration = Build();
|
await CreateStore().SaveAsync(Sample, Token);
|
||||||
configuration["Experimental:Flag"].ShouldBe("True");
|
|
||||||
configuration["Library:VideoExtensions:0"].ShouldBe(".mp4");
|
var configuration = Build();
|
||||||
}
|
configuration["Experimental:Flag"].ShouldBe("True");
|
||||||
|
configuration["Library:VideoExtensions:0"].ShouldBe(".mp4");
|
||||||
[Theory]
|
}
|
||||||
[InlineData(true)]
|
|
||||||
[InlineData(false)]
|
[Theory]
|
||||||
public void Only_changes_that_affect_the_contents_of_the_library_ask_for_a_rescan(bool cosmeticOnly)
|
[InlineData(true)]
|
||||||
{
|
[InlineData(false)]
|
||||||
var changed = cosmeticOnly
|
public void Only_changes_that_affect_the_contents_of_the_library_ask_for_a_rescan(bool cosmeticOnly)
|
||||||
? Sample with { ThumbnailWidth = 999, Theme = ThemeMode.Dark }
|
{
|
||||||
: Sample with { Folders = [@"C:\elsewhere"] };
|
var changed = cosmeticOnly
|
||||||
|
? Sample with { ThumbnailWidth = 999, Theme = ThemeMode.Dark }
|
||||||
changed.RequiresRescanComparedTo(Sample).ShouldBe(!cosmeticOnly);
|
: Sample with { Folders = [@"C:\elsewhere"] };
|
||||||
}
|
|
||||||
|
changed.RequiresRescanComparedTo(Sample).ShouldBe(!cosmeticOnly);
|
||||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
}
|
||||||
|
|
||||||
private static AppSettings Sample => new()
|
[Fact]
|
||||||
{
|
public async Task Metadata_sources_survive_the_trip_through_the_file()
|
||||||
Folders = [@"C:\videos"],
|
{
|
||||||
ThumbnailWidth = 480,
|
var store = CreateStore();
|
||||||
ThumbnailPositionRatio = 0.15,
|
|
||||||
MaxIndexingConcurrency = 4,
|
await store.SaveAsync(
|
||||||
MinimumFileSizeInBytes = 65_536,
|
Sample with
|
||||||
Theme = ThemeMode.System,
|
{
|
||||||
Volume = 0.8,
|
MetadataSources =
|
||||||
IsMuted = false,
|
[
|
||||||
};
|
new MetadataSourceOptions { Name = "StashDB", Endpoint = "https://stashdb.org/graphql", ApiKey = "секрет" },
|
||||||
|
new MetadataSourceOptions { Name = "Выключенный", Endpoint = "https://other/graphql", IsEnabled = false },
|
||||||
/// <summary>
|
],
|
||||||
/// The store composes <see cref="AppSettings.Current"/> from configuration, which these
|
},
|
||||||
/// tests do not exercise — every case here supplies the snapshot it wants to write.
|
Token);
|
||||||
/// </summary>
|
|
||||||
private JsonAppSettingsStore CreateStore() => new(
|
var metadata = new MetadataOptions();
|
||||||
_paths,
|
Build().GetSection(MetadataOptions.SectionName).Bind(metadata);
|
||||||
Monitor(new LibraryOptions()),
|
|
||||||
Monitor(new AppearanceOptions()),
|
metadata.Sources.Select(source => source.Name).ShouldBe(["StashDB", "Выключенный"]);
|
||||||
Monitor(new PlaybackOptions()));
|
metadata.Sources[0].ApiKey.ShouldBe("секрет");
|
||||||
|
metadata.Sources[0].IsUsable.ShouldBeTrue();
|
||||||
private static IOptionsMonitor<T> Monitor<T>(T value)
|
metadata.Sources[1].IsUsable.ShouldBeFalse();
|
||||||
{
|
}
|
||||||
var monitor = Substitute.For<IOptionsMonitor<T>>();
|
|
||||||
monitor.CurrentValue.Returns(value);
|
[Fact]
|
||||||
return monitor;
|
public async Task Removing_a_source_actually_shortens_the_stored_list()
|
||||||
}
|
{
|
||||||
|
var store = CreateStore();
|
||||||
private IConfigurationRoot Build() => new ConfigurationBuilder()
|
|
||||||
.AddJsonFile(Path.Combine(_paths.DataDirectory, "settings.json"), optional: false)
|
var two = Sample with
|
||||||
.Build();
|
{
|
||||||
|
MetadataSources =
|
||||||
private (LibraryOptions Library, AppearanceOptions Appearance, PlaybackOptions Playback) Reload()
|
[
|
||||||
{
|
new MetadataSourceOptions { Name = "Первый", Endpoint = "https://a/graphql" },
|
||||||
var configuration = Build();
|
new MetadataSourceOptions { Name = "Второй", Endpoint = "https://b/graphql" },
|
||||||
|
],
|
||||||
var library = new LibraryOptions();
|
};
|
||||||
configuration.GetSection(LibraryOptions.SectionName).Bind(library);
|
|
||||||
|
await store.SaveAsync(two, Token);
|
||||||
var appearance = new AppearanceOptions();
|
await store.SaveAsync(two with { MetadataSources = [two.MetadataSources[0]] }, Token);
|
||||||
configuration.GetSection(AppearanceOptions.SectionName).Bind(appearance);
|
|
||||||
|
// Configuration merges arrays by index, so the second entry is exactly what would be
|
||||||
var playback = new PlaybackOptions();
|
// left behind by a save that wrote the list element by element.
|
||||||
configuration.GetSection(PlaybackOptions.SectionName).Bind(playback);
|
var metadata = new MetadataOptions();
|
||||||
|
Build().GetSection(MetadataOptions.SectionName).Bind(metadata);
|
||||||
return (library, appearance, playback);
|
|
||||||
}
|
metadata.Sources.Select(source => source.Name).ShouldBe(["Первый"]);
|
||||||
|
}
|
||||||
private sealed class TempPaths : IAppPaths, IDisposable
|
|
||||||
{
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
public TempPaths()
|
|
||||||
{
|
private static AppSettings Sample => new()
|
||||||
DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}");
|
{
|
||||||
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
|
Folders = [@"C:\videos"],
|
||||||
PreviewDirectory = Path.Combine(DataDirectory, "previews");
|
ThumbnailWidth = 480,
|
||||||
DatabaseFile = Path.Combine(DataDirectory, "library.db");
|
ThumbnailPositionRatio = 0.15,
|
||||||
|
MaxIndexingConcurrency = 4,
|
||||||
Directory.CreateDirectory(ThumbnailDirectory);
|
MinimumFileSizeInBytes = 65_536,
|
||||||
Directory.CreateDirectory(PreviewDirectory);
|
Theme = ThemeMode.System,
|
||||||
}
|
Volume = 0.8,
|
||||||
|
IsMuted = false,
|
||||||
public string DataDirectory { get; }
|
MetadataSources = [],
|
||||||
|
};
|
||||||
public string ThumbnailDirectory { get; }
|
|
||||||
|
/// <summary>
|
||||||
public string PreviewDirectory { get; }
|
/// The store composes <see cref="AppSettings.Current"/> from configuration, which these
|
||||||
|
/// tests do not exercise — every case here supplies the snapshot it wants to write.
|
||||||
public string DatabaseFile { get; }
|
/// </summary>
|
||||||
|
private JsonAppSettingsStore CreateStore() => new(
|
||||||
public void Dispose() => Directory.Delete(DataDirectory, recursive: true);
|
_paths,
|
||||||
}
|
Monitor(new LibraryOptions()),
|
||||||
}
|
Monitor(new AppearanceOptions()),
|
||||||
|
Monitor(new PlaybackOptions()),
|
||||||
|
Monitor(new MetadataOptions()));
|
||||||
|
|
||||||
|
private static IOptionsMonitor<T> Monitor<T>(T value)
|
||||||
|
{
|
||||||
|
var monitor = Substitute.For<IOptionsMonitor<T>>();
|
||||||
|
monitor.CurrentValue.Returns(value);
|
||||||
|
return monitor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IConfigurationRoot Build() => new ConfigurationBuilder()
|
||||||
|
.AddJsonFile(Path.Combine(_paths.DataDirectory, "settings.json"), optional: false)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
private (LibraryOptions Library, AppearanceOptions Appearance, PlaybackOptions Playback) Reload()
|
||||||
|
{
|
||||||
|
var configuration = Build();
|
||||||
|
|
||||||
|
var library = new LibraryOptions();
|
||||||
|
configuration.GetSection(LibraryOptions.SectionName).Bind(library);
|
||||||
|
|
||||||
|
var appearance = new AppearanceOptions();
|
||||||
|
configuration.GetSection(AppearanceOptions.SectionName).Bind(appearance);
|
||||||
|
|
||||||
|
var playback = new PlaybackOptions();
|
||||||
|
configuration.GetSection(PlaybackOptions.SectionName).Bind(playback);
|
||||||
|
|
||||||
|
return (library, appearance, playback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TempPaths : IAppPaths, IDisposable
|
||||||
|
{
|
||||||
|
public TempPaths()
|
||||||
|
{
|
||||||
|
DataDirectory = Path.Combine(Path.GetTempPath(), $"plib-tests-{Guid.CreateVersion7()}");
|
||||||
|
ThumbnailDirectory = Path.Combine(DataDirectory, "thumbnails");
|
||||||
|
PreviewDirectory = Path.Combine(DataDirectory, "previews");
|
||||||
|
DatabaseFile = Path.Combine(DataDirectory, "library.db");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(ThumbnailDirectory);
|
||||||
|
Directory.CreateDirectory(PreviewDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DataDirectory { get; }
|
||||||
|
|
||||||
|
public string ThumbnailDirectory { get; }
|
||||||
|
|
||||||
|
public string PreviewDirectory { get; }
|
||||||
|
|
||||||
|
public string DatabaseFile { get; }
|
||||||
|
|
||||||
|
public void Dispose() => Directory.Delete(DataDirectory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using PLib.Application.Library;
|
using PLib.Application.Library;
|
||||||
|
using PLib.Application.Metadata;
|
||||||
using PLib.Desktop.Services;
|
using PLib.Desktop.Services;
|
||||||
using PLib.Desktop.Settings;
|
using PLib.Desktop.Settings;
|
||||||
using PLib.Desktop.ViewModels;
|
using PLib.Desktop.ViewModels;
|
||||||
@@ -108,7 +109,11 @@ public sealed class SettingsViewModelTests
|
|||||||
private SettingsViewModel Create(LibraryOptions options, PlaybackOptions? playback = null)
|
private SettingsViewModel Create(LibraryOptions options, PlaybackOptions? playback = null)
|
||||||
{
|
{
|
||||||
_store.Current.Returns(
|
_store.Current.Returns(
|
||||||
AppSettings.From(options, new AppearanceOptions(), playback ?? new PlaybackOptions()));
|
AppSettings.From(
|
||||||
|
options,
|
||||||
|
new AppearanceOptions(),
|
||||||
|
playback ?? new PlaybackOptions(),
|
||||||
|
new MetadataOptions()));
|
||||||
|
|
||||||
return new SettingsViewModel(
|
return new SettingsViewModel(
|
||||||
Substitute.For<IServiceScopeFactory>(),
|
Substitute.For<IServiceScopeFactory>(),
|
||||||
|
|||||||
Reference in New Issue
Block a user