351 lines
14 KiB
C#
351 lines
14 KiB
C#
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,
|
|
/// ThePornDB and their 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";
|
|
|
|
/// <summary>Below this a picture is too small for a card and gets passed over.</summary>
|
|
private const int MinimumImageWidth = 320;
|
|
|
|
/// <summary>
|
|
/// The fingerprint queries, newest first.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// stash-box renamed this query and left the old name behind, so which one an instance
|
|
/// answers depends on how recently it was updated — and the whole point of a list of
|
|
/// sources is that they are separate deployments. Asking in order and stepping past a
|
|
/// "no such field" is what lets one configuration serve both.
|
|
/// </remarks>
|
|
internal static readonly GraphQlQuery[] Queries =
|
|
[
|
|
// Takes a list of fingerprint groups and answers one group of scenes per input group,
|
|
// hence the nested result. We only ever ask about one video.
|
|
new(
|
|
Field: "findScenesBySceneFingerprints",
|
|
Document: """
|
|
query FindScenes($fingerprints: [[FingerprintQueryInput!]!]!) {
|
|
findScenesBySceneFingerprints(fingerprints: $fingerprints) {
|
|
id
|
|
title
|
|
details
|
|
images { url width }
|
|
studio { name images { url width } }
|
|
tags { name }
|
|
performers { performer { name images { url width } } }
|
|
}
|
|
}
|
|
""",
|
|
IsNested: true,
|
|
Variables: hash => new { fingerprints = new[] { new[] { new { hash, algorithm = "PHASH" } } } }),
|
|
|
|
new(
|
|
Field: "findSceneByFingerprint",
|
|
Document: """
|
|
query FindScene($fingerprint: FingerprintQueryInput!) {
|
|
findSceneByFingerprint(fingerprint: $fingerprint) {
|
|
id
|
|
title
|
|
details
|
|
images { url width }
|
|
studio { name images { url width } }
|
|
tags { name }
|
|
performers { performer { name images { url width } } }
|
|
}
|
|
}
|
|
""",
|
|
IsNested: false,
|
|
Variables: hash => new { fingerprint = new { hash, algorithm = "PHASH" } }),
|
|
];
|
|
|
|
public async Task<IReadOnlyList<VideoMetadataMatch>> FindByPerceptualHashAsync(
|
|
MetadataSourceOptions source,
|
|
ulong perceptualHash,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(source);
|
|
|
|
// 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.
|
|
var hash = perceptualHash.ToString("x16", CultureInfo.InvariantCulture);
|
|
|
|
UnsupportedQueryException? unsupported = null;
|
|
|
|
foreach (var query in Queries)
|
|
{
|
|
try
|
|
{
|
|
return await ExecuteAsync(source, query, hash, cancellationToken);
|
|
}
|
|
catch (UnsupportedQueryException ex)
|
|
{
|
|
logger.LogDebug(
|
|
"Source {Source} does not know {Field}; trying the next query",
|
|
source.Name,
|
|
query.Field);
|
|
|
|
unsupported = ex;
|
|
}
|
|
}
|
|
|
|
throw new InvalidOperationException(
|
|
"не отвечает ни на один известный запрос по отпечатку — похоже, это не stash-box",
|
|
unsupported);
|
|
}
|
|
|
|
private async Task<IReadOnlyList<VideoMetadataMatch>> ExecuteAsync(
|
|
MetadataSourceOptions source,
|
|
GraphQlQuery query,
|
|
string hash,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, source.Endpoint)
|
|
{
|
|
Content = JsonContent.Create(new { query = query.Document, variables = query.Variables(hash) }),
|
|
};
|
|
|
|
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);
|
|
|
|
return ReadPayload(document.RootElement, query, source.Name);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Turns one GraphQL reply into matches, or throws describing why it is not one.
|
|
/// </summary>
|
|
internal static IReadOnlyList<VideoMetadataMatch> ReadPayload(
|
|
JsonElement root,
|
|
GraphQlQuery query,
|
|
string sourceName)
|
|
{
|
|
ThrowOnGraphQlErrors(root, query);
|
|
|
|
if (!root.TryGetProperty("data", out var data) ||
|
|
!data.TryGetProperty(query.Field, out var result) ||
|
|
result.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
// The newer query answers per input group, so its result is one level deeper. We ask
|
|
// about a single video, which makes flattening the whole of the difference.
|
|
var scenes = query.IsNested
|
|
? result.EnumerateArray().Where(group => group.ValueKind == JsonValueKind.Array).SelectMany(group => group.EnumerateArray())
|
|
: result.EnumerateArray();
|
|
|
|
return [.. scenes.Select(scene => ReadScene(scene, sourceName))];
|
|
}
|
|
|
|
/// <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, GraphQlQuery query)
|
|
{
|
|
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))
|
|
.Select(message => message!)
|
|
.ToArray();
|
|
|
|
var combined = string.Join("; ", messages);
|
|
|
|
// A rejected field is not a failure — it is an older instance, and there is another
|
|
// query to try. Everything else (a bad key, a rate limit) is final.
|
|
if (messages.Any(message => DescribesMissingField(message, query.Field)))
|
|
{
|
|
throw new UnsupportedQueryException(combined);
|
|
}
|
|
|
|
// Never prefixed with the source name: the caller already reports which source spoke,
|
|
// and prefixing here is how the message ends up saying it twice.
|
|
throw new InvalidOperationException(combined);
|
|
}
|
|
|
|
private static bool DescribesMissingField(string message, string field) =>
|
|
message.Contains(field, StringComparison.Ordinal) &&
|
|
(message.Contains("Cannot query field", StringComparison.OrdinalIgnoreCase) ||
|
|
message.Contains("Unknown field", StringComparison.OrdinalIgnoreCase));
|
|
|
|
private static VideoMetadataMatch ReadScene(JsonElement scene, string sourceName) => new(
|
|
sourceName,
|
|
Text(scene, "id"),
|
|
Text(scene, "title") ?? "Без названия",
|
|
Text(scene, "details"),
|
|
Entities(scene, "tags"),
|
|
Performers(scene),
|
|
Studios(scene),
|
|
|
|
// The scene's own cover. Left as a URL here — fetching it is the application layer's
|
|
// call, because whether it is worth downloading depends on what asked for the match.
|
|
PickImage(scene));
|
|
|
|
/// <summary>
|
|
/// A string property, or <c>null</c> for anything else — including when the element itself
|
|
/// is a JSON null. Half the fields on a scene are optional, and a null studio is a normal
|
|
/// answer rather than a malformed one.
|
|
/// </summary>
|
|
private static string? Text(JsonElement element, string property) =>
|
|
element.ValueKind == JsonValueKind.Object &&
|
|
element.TryGetProperty(property, out var value) &&
|
|
value.ValueKind == JsonValueKind.String
|
|
? value.GetString()
|
|
: null;
|
|
|
|
/// <summary>A named array — tags, and anything else shaped like them.</summary>
|
|
private static IReadOnlyList<MetadataEntity> Entities(JsonElement scene, string property)
|
|
{
|
|
if (scene.ValueKind != JsonValueKind.Object ||
|
|
!scene.TryGetProperty(property, out var array) ||
|
|
array.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return [.. array.EnumerateArray().Select(ReadEntity).OfType<MetadataEntity>()];
|
|
}
|
|
|
|
/// <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 we want, name and face both.
|
|
/// </summary>
|
|
private static IReadOnlyList<MetadataEntity> Performers(JsonElement scene)
|
|
{
|
|
if (scene.ValueKind != JsonValueKind.Object ||
|
|
!scene.TryGetProperty("performers", out var array) ||
|
|
array.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return
|
|
[
|
|
.. array
|
|
.EnumerateArray()
|
|
.Select(appearance => appearance.ValueKind == JsonValueKind.Object &&
|
|
appearance.TryGetProperty("performer", out var performer)
|
|
? ReadEntity(performer)
|
|
: null)
|
|
.OfType<MetadataEntity>()
|
|
];
|
|
}
|
|
|
|
/// <summary>
|
|
/// A scene has at most one studio, and often none — the shape is a list because a match
|
|
/// may have nothing to say here.
|
|
/// </summary>
|
|
private static IReadOnlyList<MetadataEntity> Studios(JsonElement scene) =>
|
|
scene.ValueKind == JsonValueKind.Object &&
|
|
scene.TryGetProperty("studio", out var studio) &&
|
|
ReadEntity(studio) is { } entity
|
|
? [entity]
|
|
: [];
|
|
|
|
private static MetadataEntity? ReadEntity(JsonElement element) =>
|
|
Text(element, "name") is { } name ? new MetadataEntity(name, PickImage(element)) : null;
|
|
|
|
/// <summary>
|
|
/// Picks the picture to keep: the smallest one still wide enough for a card, and the
|
|
/// widest available when none reaches that.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// stash-box returns every size it holds, and the first is not the best — originals run to
|
|
/// several thousand pixels. Downloading one of those per performer to draw it 150 pixels
|
|
/// wide would cost megabytes a head and look no better for it. Tags have no images field
|
|
/// at all, so this simply finds nothing for them.
|
|
/// </remarks>
|
|
private static string? PickImage(JsonElement owner)
|
|
{
|
|
if (owner.ValueKind != JsonValueKind.Object ||
|
|
!owner.TryGetProperty("images", out var images) ||
|
|
images.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var candidates = images
|
|
.EnumerateArray()
|
|
.Where(image => image.ValueKind == JsonValueKind.Object)
|
|
.Select(image => (Url: Text(image, "url"), Width: Number(image, "width")))
|
|
.Where(image => !string.IsNullOrWhiteSpace(image.Url))
|
|
.ToList();
|
|
|
|
if (candidates.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var enough = candidates.Where(image => image.Width >= MinimumImageWidth).ToList();
|
|
|
|
return enough.Count > 0
|
|
? enough.MinBy(image => image.Width).Url
|
|
: candidates.MaxBy(image => image.Width).Url;
|
|
}
|
|
|
|
private static int Number(JsonElement element, string property) =>
|
|
element.TryGetProperty(property, out var value) &&
|
|
value.ValueKind == JsonValueKind.Number &&
|
|
value.TryGetInt32(out var number)
|
|
? number
|
|
: 0;
|
|
|
|
/// <summary>One way of asking the same question, and how to read the answer.</summary>
|
|
/// <param name="Field">Name of the query root field, used to find it in the reply.</param>
|
|
/// <param name="IsNested">True when the result is a list of lists rather than a list.</param>
|
|
internal sealed record GraphQlQuery(
|
|
string Field,
|
|
string Document,
|
|
bool IsNested,
|
|
Func<string, object> Variables);
|
|
|
|
/// <summary>Raised when the server does not have the field this query asks for.</summary>
|
|
private sealed class UnsupportedQueryException(string message) : Exception(message);
|
|
}
|