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:
@@ -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]
|
||||
: [];
|
||||
}
|
||||
Reference in New Issue
Block a user