Update README.md to reflect changes in stash-box schema and query handling. Introduce new GraphQL queries for scene retrieval by fingerprints in StashBoxMetadataProvider, enhancing metadata lookup capabilities. Add InternalsVisibleTo attribute for testing access in PLib.Infrastructure project.
This commit is contained in:
@@ -11,9 +11,9 @@ namespace PLib.Infrastructure.Metadata;
|
||||
/// 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
|
||||
/// 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>
|
||||
@@ -27,18 +27,53 @@ public sealed class StashBoxMetadataProvider(
|
||||
/// <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 } }
|
||||
}
|
||||
}
|
||||
""";
|
||||
/// <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
|
||||
studio { name }
|
||||
tags { name }
|
||||
performers { performer { name } }
|
||||
}
|
||||
}
|
||||
""",
|
||||
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
|
||||
studio { name }
|
||||
tags { name }
|
||||
performers { performer { name } }
|
||||
}
|
||||
}
|
||||
""",
|
||||
IsNested: false,
|
||||
Variables: hash => new { fingerprint = new { hash, algorithm = "PHASH" } }),
|
||||
];
|
||||
|
||||
public async Task<IReadOnlyList<VideoMetadataMatch>> FindByPerceptualHashAsync(
|
||||
MetadataSourceOptions source,
|
||||
@@ -47,15 +82,43 @@ public sealed class StashBoxMetadataProvider(
|
||||
{
|
||||
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)
|
||||
{
|
||||
// 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) },
|
||||
}),
|
||||
Content = JsonContent.Create(new { query = query.Document, variables = query.Variables(hash) }),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(source.ApiKey))
|
||||
@@ -78,24 +141,40 @@ public sealed class StashBoxMetadataProvider(
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
|
||||
ThrowOnGraphQlErrors(document.RootElement, source.Name);
|
||||
return ReadPayload(document.RootElement, query, source.Name);
|
||||
}
|
||||
|
||||
if (!document.RootElement.TryGetProperty("data", out var data) ||
|
||||
!data.TryGetProperty("findSceneByFingerprint", out var scenes) ||
|
||||
scenes.ValueKind != JsonValueKind.Array)
|
||||
/// <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)
|
||||
{
|
||||
logger.LogDebug("Source {Source} returned no scenes element", source.Name);
|
||||
return [];
|
||||
}
|
||||
|
||||
return [.. scenes.EnumerateArray().Select(scene => ReadScene(scene, source.Name))];
|
||||
// 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, string sourceName)
|
||||
private static void ThrowOnGraphQlErrors(JsonElement root, GraphQlQuery query)
|
||||
{
|
||||
if (!root.TryGetProperty("errors", out var errors) ||
|
||||
errors.ValueKind != JsonValueKind.Array ||
|
||||
@@ -106,14 +185,30 @@ public sealed class StashBoxMetadataProvider(
|
||||
|
||||
var messages = errors
|
||||
.EnumerateArray()
|
||||
.Select(error => error.TryGetProperty("message", out var message)
|
||||
? message.GetString()
|
||||
: null)
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message));
|
||||
.Select(error => error.TryGetProperty("message", out var message) ? message.GetString() : null)
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message))
|
||||
.Select(message => message!)
|
||||
.ToArray();
|
||||
|
||||
throw new InvalidOperationException($"{sourceName}: {string.Join("; ", messages)}");
|
||||
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"),
|
||||
@@ -123,14 +218,23 @@ public sealed class StashBoxMetadataProvider(
|
||||
Performers(scene),
|
||||
Studios(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.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String
|
||||
element.ValueKind == JsonValueKind.Object &&
|
||||
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)
|
||||
if (scene.ValueKind != JsonValueKind.Object ||
|
||||
!scene.TryGetProperty(property, out var array) ||
|
||||
array.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
@@ -144,7 +248,9 @@ public sealed class StashBoxMetadataProvider(
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string> Performers(JsonElement scene)
|
||||
{
|
||||
if (!scene.TryGetProperty("performers", out var array) || array.ValueKind != JsonValueKind.Array)
|
||||
if (scene.ValueKind != JsonValueKind.Object ||
|
||||
!scene.TryGetProperty("performers", out var array) ||
|
||||
array.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
@@ -153,16 +259,34 @@ public sealed class StashBoxMetadataProvider(
|
||||
[
|
||||
.. array
|
||||
.EnumerateArray()
|
||||
.Select(appearance => appearance.TryGetProperty("performer", out var performer)
|
||||
.Select(appearance => appearance.ValueKind == JsonValueKind.Object &&
|
||||
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>
|
||||
/// <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<string> Studios(JsonElement scene) =>
|
||||
scene.TryGetProperty("studio", out var studio) && Text(studio, "name") is { } name
|
||||
scene.ValueKind == JsonValueKind.Object &&
|
||||
scene.TryGetProperty("studio", out var studio) &&
|
||||
Text(studio, "name") is { } name
|
||||
? [name]
|
||||
: [];
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user