Files
PLib/src/PLib.Infrastructure/Metadata/StashBoxMetadataProvider.cs
T

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);
}