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; /// /// Looks a video up by perceptual hash against a stash-box GraphQL endpoint. /// /// /// 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 {query, variables} /// and one object to read out of the reply, and a dependency to build that string would be /// larger than the code it replaced. /// public sealed class StashBoxMetadataProvider( IHttpClientFactory httpClientFactory, ILogger logger) : IMetadataProvider { /// Name of the configured ; see the DI registration. public const string HttpClientName = "metadata"; /// Below this a picture is too small for a card and gets passed over. private const int MinimumImageWidth = 320; /// /// The fingerprint queries, newest first. /// /// /// 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. /// 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> 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> 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); } /// /// Turns one GraphQL reply into matches, or throws describing why it is not one. /// internal static IReadOnlyList 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))]; } /// /// A GraphQL server answers 200 with an errors array, so a bad key or a schema /// mismatch would otherwise read as "this source knows nothing about your video". /// 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)); /// /// A string property, or null 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. /// 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; /// A named array — tags, and anything else shaped like them. private static IReadOnlyList 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()]; } /// /// 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. /// private static IReadOnlyList 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() ]; } /// /// A scene has at most one studio, and often none — the shape is a list because a match /// may have nothing to say here. /// private static IReadOnlyList 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; /// /// Picks the picture to keep: the smallest one still wide enough for a card, and the /// widest available when none reaches that. /// /// /// 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. /// 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; /// One way of asking the same question, and how to read the answer. /// Name of the query root field, used to find it in the reply. /// True when the result is a list of lists rather than a list. internal sealed record GraphQlQuery( string Field, string Document, bool IsNested, Func Variables); /// Raised when the server does not have the field this query asks for. private sealed class UnsupportedQueryException(string message) : Exception(message); }