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:
Leonid Pershin
2026-08-09 08:34:17 +03:00
parent eb4894428b
commit 61d01d1970
4 changed files with 313 additions and 42 deletions
+9 -2
View File
@@ -148,8 +148,15 @@ dotnet test
Найденное не применяется само: отпечатки совпадают у перекодировок и трейлеров, а молча
переписанное название откатывать куда дороже, чем нажать кнопку. Применение добавляет метки,
но не удаляет чужие — то, что проставил пользователь, остаётся.
Схема — stash-box (StashDB и родственники): именно поэтому список источников вообще имеет
смысл, ведь это разные экземпляры одного сервера, отвечающие на один и тот же запрос.
Схема — stash-box (StashDB, ThePornDB и родственники): именно поэтому список источников
вообще имеет смысл, ведь это разные экземпляры одного сервера, отвечающие на один и тот же
запрос. Запросов, впрочем, два: stash-box переименовал `findSceneByFingerprint` в
`findScenesBySceneFingerprints` и оставил старое имя позади, так что какой из них знает
конкретный экземпляр — зависит от того, когда его обновляли. Они пробуются по очереди,
и «нет такого поля» ведёт к следующему, а не к ошибке; всё прочее (неверный ключ, лимит)
окончательно. Новый запрос отвечает группой сцен на группу отпечатков, поэтому его результат
на уровень глубже — про одно видео мы спрашиваем всегда, так что разница сводится к
выпрямлению списка.
GraphQL-клиента в зависимостях нет: весь разговор — один POST с `{query, variables}` и один
объект в ответе.
**API-ключи лежат в `settings.json` открытым текстом** — там же и с той же защитой, что и
@@ -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);
}
@@ -20,4 +20,11 @@
<ProjectReference Include="..\PLib.Application\PLib.Application.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Decoding a GraphQL reply is the part of this assembly most likely to be wrong and the
hardest to reach through the public surface; the tests get at it directly rather than
the surface being widened to let them in. -->
<InternalsVisibleTo Include="PLib.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,133 @@
using System.Text.Json;
using PLib.Infrastructure.Metadata;
using Shouldly;
namespace PLib.Tests.Metadata;
/// <summary>
/// Decoding a stash-box reply, against payloads shaped like the real ones.
/// </summary>
/// <remarks>
/// This is the part of the feature that cannot be checked without a server and a key, and the
/// part where a wrong guess looks exactly like "the source knows nothing about your video".
/// </remarks>
public sealed class StashBoxPayloadTests
{
/// <summary>The newer query, whose result is one group of scenes per group of fingerprints.</summary>
private static StashBoxMetadataProvider.GraphQlQuery Nested => StashBoxMetadataProvider.Queries[0];
/// <summary>The older query, kept for instances that have not been updated.</summary>
private static StashBoxMetadataProvider.GraphQlQuery Flat => StashBoxMetadataProvider.Queries[1];
[Fact]
public void The_newer_query_returns_scenes_one_level_deeper_and_they_are_flattened()
{
const string payload = """
{
"data": {
"findScenesBySceneFingerprints": [
[
{
"id": "abc",
"title": "Первая",
"details": "Описание",
"studio": { "name": "Студия" },
"tags": [ { "name": "драма" }, { "name": "нуар" } ],
"performers": [ { "performer": { "name": "Актёр" }, "as": "Псевдоним" } ]
},
{ "id": "def", "title": "Вторая", "studio": null, "tags": [], "performers": [] }
]
]
}
}
""";
var matches = Read(payload, Nested);
matches.Count.ShouldBe(2);
var first = matches[0];
first.SourceName.ShouldBe("StashDB");
first.RemoteId.ShouldBe("abc");
first.Title.ShouldBe("Первая");
first.Description.ShouldBe("Описание");
first.Studios.ShouldBe(["Студия"]);
first.Tags.ShouldBe(["драма", "нуар"]);
// The credited alias is not the person; the label has to be the performer's own name.
first.Performers.ShouldBe(["Актёр"]);
matches[1].Studios.ShouldBeEmpty();
}
[Fact]
public void The_older_query_returns_a_flat_list()
{
const string payload = """
{
"data": {
"findSceneByFingerprint": [
{ "id": "abc", "title": "Сцена", "studio": { "name": "Студия" }, "tags": [], "performers": [] }
]
}
}
""";
Read(payload, Flat).ShouldHaveSingleItem().Title.ShouldBe("Сцена");
}
[Fact]
public void A_reply_with_no_matches_is_an_empty_list_rather_than_a_failure()
{
Read("""{ "data": { "findScenesBySceneFingerprints": [ [] ] } }""", Nested).ShouldBeEmpty();
Read("""{ "data": { "findScenesBySceneFingerprints": null } }""", Nested).ShouldBeEmpty();
}
[Fact]
public void A_scene_without_a_title_still_produces_a_match_that_can_be_shown()
{
var match = Read(
"""{ "data": { "findSceneByFingerprint": [ { "id": "abc", "title": null } ] } }""",
Flat).ShouldHaveSingleItem();
match.Title.ShouldNotBeNullOrWhiteSpace();
match.Tags.ShouldBeEmpty();
}
[Fact]
public void Errors_are_raised_even_though_the_server_answered_two_hundred()
{
// A rejected key comes back as 200 with an errors array; treating that as "nothing
// found" would tell the user their video is unknown rather than their key is wrong.
var payload = """{ "errors": [ { "message": "unauthorized" } ], "data": null }""";
Should.Throw<InvalidOperationException>(() => Read(payload, Nested))
.Message.ShouldBe("unauthorized");
}
[Fact]
public void A_missing_field_is_reported_apart_from_other_errors_so_the_older_query_gets_a_turn()
{
// Verbatim from a live instance that only has the newer query.
var payload = """
{
"errors": [
{ "message": "Cannot query field \"findSceneByFingerprint\" on type \"Query\". Did you mean \"findScenesBySceneFingerprints\"?" }
]
}
""";
var rejected = Should.Throw<Exception>(() => Read(payload, Flat));
// Not the type the caller reports as a failure: this one means "try the next query".
rejected.ShouldNotBeOfType<InvalidOperationException>();
}
private static IReadOnlyList<PLib.Application.Metadata.VideoMetadataMatch> Read(
string payload,
StashBoxMetadataProvider.GraphQlQuery query)
{
using var document = JsonDocument.Parse(payload);
return StashBoxMetadataProvider.ReadPayload(document.RootElement, query, "StashDB");
}
}