diff --git a/README.md b/README.md index 9735c8b..d2c9dad 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,15 @@ dotnet test Найденное не применяется само: отпечатки совпадают у перекодировок и трейлеров, а молча переписанное название откатывать куда дороже, чем нажать кнопку. Применение добавляет метки, но не удаляет чужие — то, что проставил пользователь, остаётся. - Схема — stash-box (StashDB и родственники): именно поэтому список источников вообще имеет - смысл, ведь это разные экземпляры одного сервера, отвечающие на один и тот же запрос. + Схема — stash-box (StashDB, ThePornDB и родственники): именно поэтому список источников + вообще имеет смысл, ведь это разные экземпляры одного сервера, отвечающие на один и тот же + запрос. Запросов, впрочем, два: stash-box переименовал `findSceneByFingerprint` в + `findScenesBySceneFingerprints` и оставил старое имя позади, так что какой из них знает + конкретный экземпляр — зависит от того, когда его обновляли. Они пробуются по очереди, + и «нет такого поля» ведёт к следующему, а не к ошибке; всё прочее (неверный ключ, лимит) + окончательно. Новый запрос отвечает группой сцен на группу отпечатков, поэтому его результат + на уровень глубже — про одно видео мы спрашиваем всегда, так что разница сводится к + выпрямлению списка. GraphQL-клиента в зависимостях нет: весь разговор — один POST с `{query, variables}` и один объект в ответе. **API-ключи лежат в `settings.json` открытым текстом** — там же и с той же защитой, что и diff --git a/src/PLib.Infrastructure/Metadata/StashBoxMetadataProvider.cs b/src/PLib.Infrastructure/Metadata/StashBoxMetadataProvider.cs index 24907f4..aff3077 100644 --- a/src/PLib.Infrastructure/Metadata/StashBoxMetadataProvider.cs +++ b/src/PLib.Infrastructure/Metadata/StashBoxMetadataProvider.cs @@ -11,9 +11,9 @@ 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 -/// 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 {query, variables} @@ -27,18 +27,53 @@ public sealed class StashBoxMetadataProvider( /// Name of the configured ; see the DI registration. 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 } } - } - } - """; + /// + /// 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 + 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> 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> 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) + /// + /// 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) { - 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))]; } /// /// 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, 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)); + /// + /// 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.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 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( /// private static IReadOnlyList 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() ]; } - /// A scene has at most one studio; the shape is a list because a match may not. + /// + /// 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.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] : []; + + /// 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); } diff --git a/src/PLib.Infrastructure/PLib.Infrastructure.csproj b/src/PLib.Infrastructure/PLib.Infrastructure.csproj index 2bc2e46..2f567a2 100644 --- a/src/PLib.Infrastructure/PLib.Infrastructure.csproj +++ b/src/PLib.Infrastructure/PLib.Infrastructure.csproj @@ -20,4 +20,11 @@ + + + + + diff --git a/tests/PLib.Tests/Metadata/StashBoxPayloadTests.cs b/tests/PLib.Tests/Metadata/StashBoxPayloadTests.cs new file mode 100644 index 0000000..0a545cf --- /dev/null +++ b/tests/PLib.Tests/Metadata/StashBoxPayloadTests.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using PLib.Infrastructure.Metadata; +using Shouldly; + +namespace PLib.Tests.Metadata; + +/// +/// Decoding a stash-box reply, against payloads shaped like the real ones. +/// +/// +/// 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". +/// +public sealed class StashBoxPayloadTests +{ + /// The newer query, whose result is one group of scenes per group of fingerprints. + private static StashBoxMetadataProvider.GraphQlQuery Nested => StashBoxMetadataProvider.Queries[0]; + + /// The older query, kept for instances that have not been updated. + 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(() => 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(() => Read(payload, Flat)); + + // Not the type the caller reports as a failure: this one means "try the next query". + rejected.ShouldNotBeOfType(); + } + + private static IReadOnlyList Read( + string payload, + StashBoxMetadataProvider.GraphQlQuery query) + { + using var document = JsonDocument.Parse(payload); + return StashBoxMetadataProvider.ReadPayload(document.RootElement, query, "StashDB"); + } +}