From f250276a99690ab91abfe1546b789882c62949eb Mon Sep 17 00:00:00 2001 From: Brian Bicknell Date: Mon, 8 Sep 2025 17:08:47 -0400 Subject: [PATCH] Implemented DLSiteClient. --- JSMR.Api/JSMR.Api.http | 4 +- JSMR.Api/Program.cs | 2 +- .../DLSite/Models/VoiceWorkDetails.cs | 3 +- ...frastructureServiceCollectionExtensions.cs | 25 ++ .../VoiceWorkSearchConfiguration.cs | 2 +- .../VoiceWorks/MySqlBooleanQuery.cs | 173 +++++++++++++ .../MySqlVoiceWorkFullTextSearch.cs | 167 ------------ JSMR.Infrastructure/Http/ApiClient.cs | 79 ++++++ .../Integrations/Chobit/ChobitClient.cs | 15 ++ .../Chobit/Models/ChobitResponse.cs | 3 + .../Chobit/Models/ChobitResult.cs | 12 + .../Integrations/Chobit/Models/ChobitWork.cs | 39 +++ .../Integrations/DLSite/DLSiteClient.cs | 28 ++ .../DLSite/DLSiteClientRegistration.cs | 27 ++ .../DLSite/Mapping/DLSiteToDomainMapper.cs | 131 ++++++++++ .../DLSite/Models/DownloadCountItem.cs | 32 +++ .../DLSite/Models/ProductBonus.cs | 23 ++ .../Integrations/DLSite/Models/ProductInfo.cs | 245 ++++++++++++++++++ .../DLSite/Models/ProductInfoCollection.cs | 6 + .../Integrations/DLSite/Models/ProductRank.cs | 18 ++ .../DLSite/Models/ProductRateCountDetail.cs | 15 ++ .../DLSite/Models/ProductReview.cs | 81 ++++++ .../DLSite/Models/ProductReviewCollection.cs | 45 ++++ .../DLSite/Models/ProductReviewerGenre.cs | 15 ++ .../DLSite/Models/ProductTranslationInfo.cs | 36 +++ .../DLSite/Models/RelatedProductSeries.cs | 15 ++ .../DLSite/Models/RelatedProductSeriesData.cs | 9 + .../DLSite/Serialization/DLSiteJsonContext.cs | 23 ++ .../DateTimeConverterUsingDateTimeParse.cs | 17 ++ .../Serialization/DictionaryConverter.cs | 13 + ...ableDateTimeConverterUsingDateTimeParse.cs | 35 +++ .../DLSite/Serialization/NumberConverter.cs | 23 ++ .../ProductInfoOptionsConverter.cs | 22 ++ .../VoiceWorkSearchProviderTests.cs | 2 +- .../Integrations/DLSite/DLSiteClientTests.cs | 86 ++++++ .../Integrations/DLSite/Product-Info.json | 223 ++++++++++++++++ JSMR.Tests/JSMR.Tests.csproj | 9 + JSMR.Tests/Unit/MySqlBooleanQueryTests.cs | 31 +++ JSMR.Tests/Utilities/ResourceHelper.cs | 23 ++ 39 files changed, 1584 insertions(+), 173 deletions(-) create mode 100644 JSMR.Infrastructure/Data/Repositories/VoiceWorks/MySqlBooleanQuery.cs create mode 100644 JSMR.Infrastructure/Http/ApiClient.cs create mode 100644 JSMR.Infrastructure/Integrations/Chobit/ChobitClient.cs create mode 100644 JSMR.Infrastructure/Integrations/Chobit/Models/ChobitResponse.cs create mode 100644 JSMR.Infrastructure/Integrations/Chobit/Models/ChobitResult.cs create mode 100644 JSMR.Infrastructure/Integrations/Chobit/Models/ChobitWork.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/DLSiteClient.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/DLSiteClientRegistration.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Mapping/DLSiteToDomainMapper.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Models/DownloadCountItem.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Models/ProductBonus.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Models/ProductInfo.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Models/ProductInfoCollection.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Models/ProductRank.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Models/ProductRateCountDetail.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Models/ProductReview.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Models/ProductReviewCollection.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Models/ProductReviewerGenre.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Models/ProductTranslationInfo.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Models/RelatedProductSeries.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Models/RelatedProductSeriesData.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Serialization/DLSiteJsonContext.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Serialization/DateTimeConverterUsingDateTimeParse.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Serialization/DictionaryConverter.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Serialization/NullableDateTimeConverterUsingDateTimeParse.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Serialization/NumberConverter.cs create mode 100644 JSMR.Infrastructure/Integrations/DLSite/Serialization/ProductInfoOptionsConverter.cs create mode 100644 JSMR.Tests/Integrations/DLSite/DLSiteClientTests.cs create mode 100644 JSMR.Tests/Integrations/DLSite/Product-Info.json create mode 100644 JSMR.Tests/Unit/MySqlBooleanQueryTests.cs create mode 100644 JSMR.Tests/Utilities/ResourceHelper.cs diff --git a/JSMR.Api/JSMR.Api.http b/JSMR.Api/JSMR.Api.http index 3b0624b..cb49ff2 100644 --- a/JSMR.Api/JSMR.Api.http +++ b/JSMR.Api/JSMR.Api.http @@ -45,10 +45,10 @@ Content-Type: {{contentType}} { "options": { "criteria": { - "keywords": "tsundere maid" + "keywords": "tsundere" }, "pageNumber": 1, - "pageSize": 20, + "pageSize": 100, "sortOptions": [ { "field": "Downloads", "direction": "Descending" } ] diff --git a/JSMR.Api/Program.cs b/JSMR.Api/Program.cs index fa1ea27..64f9e2f 100644 --- a/JSMR.Api/Program.cs +++ b/JSMR.Api/Program.cs @@ -20,7 +20,7 @@ builder.Services // DbContext (MySQL here; swap to Npgsql when you migrate) var cs = builder.Configuration.GetConnectionString("AppDb") - ?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb"); + ?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb2"); builder.Services.AddDbContext(opt => opt.UseMySql(cs, ServerVersion.AutoDetect(cs)) diff --git a/JSMR.Application/Integrations/DLSite/Models/VoiceWorkDetails.cs b/JSMR.Application/Integrations/DLSite/Models/VoiceWorkDetails.cs index aee063a..22b3940 100644 --- a/JSMR.Application/Integrations/DLSite/Models/VoiceWorkDetails.cs +++ b/JSMR.Application/Integrations/DLSite/Models/VoiceWorkDetails.cs @@ -10,7 +10,8 @@ public class VoiceWorkDetails public int DownloadCount { get; init; } public DateTime? RegistrationDate { get; init; } public Language[] SupportedLanguages { get; init; } = []; - //public AIGeneration AI { get; init; } + public AIGeneration AI { get; init; } public bool HasTrial { get; init; } + public bool HasDLPlay { get; init; } public bool HasReviews { get; init; } } \ No newline at end of file diff --git a/JSMR.Infrastructure/DI/InfrastructureServiceCollectionExtensions.cs b/JSMR.Infrastructure/DI/InfrastructureServiceCollectionExtensions.cs index 855b258..2752e1d 100644 --- a/JSMR.Infrastructure/DI/InfrastructureServiceCollectionExtensions.cs +++ b/JSMR.Infrastructure/DI/InfrastructureServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using JSMR.Application.Circles.Queries.Search; using JSMR.Application.Common.Caching; using JSMR.Application.Creators.Ports; using JSMR.Application.Creators.Queries.Search.Ports; +using JSMR.Application.Integrations.Ports; using JSMR.Application.Tags.Ports; using JSMR.Application.Tags.Queries.Search.Ports; using JSMR.Application.VoiceWorks.Queries.Search; @@ -12,6 +13,7 @@ using JSMR.Infrastructure.Data.Repositories.Circles; using JSMR.Infrastructure.Data.Repositories.Creators; using JSMR.Infrastructure.Data.Repositories.Tags; using JSMR.Infrastructure.Data.Repositories.VoiceWorks; +using JSMR.Infrastructure.Integrations.DLSite; using Microsoft.Extensions.DependencyInjection; namespace JSMR.Infrastructure.DI; @@ -37,4 +39,27 @@ public static class InfrastructureServiceCollectionExtensions return services; } + + //public static IServiceCollection AddDLSiteClient(this IServiceCollection services) + //{ + // var retryPolicy = HttpPolicyExtensions + // .HandleTransientHttpError() + // .OrResult(msg => (int)msg.StatusCode == 429) // Too Many Requests + // .WaitAndRetryAsync(new[] + // { + // TimeSpan.FromMilliseconds(200), + // TimeSpan.FromMilliseconds(500), + // TimeSpan.FromSeconds(1.5) + // }); + + // services.AddHttpClient(c => + // { + // c.BaseAddress = new Uri("https://www.dlsite.com/"); + // c.DefaultRequestHeaders.UserAgent.ParseAdd("JSMR/1.0 (+contact@example.com)"); + // c.Timeout = TimeSpan.FromSeconds(15); + // }) + // .AddPolicyHandler(retryPolicy); + + // return services; + //} } \ No newline at end of file diff --git a/JSMR.Infrastructure/Data/Configuration/VoiceWorkSearchConfiguration.cs b/JSMR.Infrastructure/Data/Configuration/VoiceWorkSearchConfiguration.cs index d1f46c1..a446656 100644 --- a/JSMR.Infrastructure/Data/Configuration/VoiceWorkSearchConfiguration.cs +++ b/JSMR.Infrastructure/Data/Configuration/VoiceWorkSearchConfiguration.cs @@ -8,7 +8,7 @@ public sealed class VoiceWorkSearchConfiguration : IEntityTypeConfiguration builder) { - builder.ToTable("VoiceWorkSearches"); + builder.ToTable("VoiceWorkSearches2"); builder.HasKey(x => x.VoiceWorkId); // also the FK builder.Property(x => x.SearchText).IsRequired(); // TEXT/LONGTEXT diff --git a/JSMR.Infrastructure/Data/Repositories/VoiceWorks/MySqlBooleanQuery.cs b/JSMR.Infrastructure/Data/Repositories/VoiceWorks/MySqlBooleanQuery.cs new file mode 100644 index 0000000..d68ea86 --- /dev/null +++ b/JSMR.Infrastructure/Data/Repositories/VoiceWorks/MySqlBooleanQuery.cs @@ -0,0 +1,173 @@ +using System.Text; + +namespace JSMR.Infrastructure.Data.Repositories.VoiceWorks; + +public static class MySqlBooleanQuery +{ + // Main entry + public static string Normalize(string input) + { + if (string.IsNullOrWhiteSpace(input)) return string.Empty; + + // Split into top-level tokens by spaces (not inside quotes/parentheses) + var tokens = SplitTopLevel(input.Trim(), ' '); + var parts = new List(tokens.Count); + + foreach (var raw in tokens) + { + var t = raw.Trim(); + if (t.Length == 0) continue; + + // Preserve explicit boolean operators user may already supply + if (t[0] == '-' || t[0] == '+') + { + if (t.Length == 1) + continue; + + // Token already has a sign; normalize rest + var sign = t[0]; + var body = t[1..].Trim(); + parts.Add(sign + NormalizePositive(body)); + continue; + } + + // Default: required term + parts.Add("+" + NormalizePositive(t)); + } + + return string.Join(' ', parts.Where(p => p.Length > 1 || p == "+" || p == "-")).Trim(); + } + + // Normalize a positive (non-signed) token: handles ORs, phrases, grouping + private static string NormalizePositive(string token) + { + if (string.IsNullOrWhiteSpace(token)) return string.Empty; + + // If token is a quoted phrase already -> keep quotes + if (IsQuoted(token)) + { + return EnsureBalancedQuotes(token); + } + + // If token starts/ends with parentheses, leave as-is (user grouping) + if (token.StartsWith('(') && token.EndsWith(')') && token.Length > 2) + { + // Optionally normalize inside the group if you want; + // here we trust user's grouping. + return token; + } + + // If token contains OR '|' at top level, convert to (a|b|...) + if (ContainsTopLevel(token, '|')) + { + var orParts = SplitTopLevel(token, '|') + .Select(p => NormalizeOrAtom(p.Trim())) + .Where(p => p.Length > 0); + return "(" + string.Join("|", orParts) + ")"; + } + + // Plain atom -> as-is + return token; + } + + // Normalize one OR-side atom (could be phrase or bare word) + private static string NormalizeOrAtom(string atom) + { + if (string.IsNullOrWhiteSpace(atom)) return string.Empty; + + // Allow nested quotes inside OR + if (IsQuoted(atom)) return EnsureBalancedQuotes(atom); + + // If it contains whitespace, quote it to become a phrase + if (atom.Any(char.IsWhiteSpace)) + return $"\"{EscapeQuotes(atom)}\""; + + return atom; + } + + // -------------- helpers -------------- + + private static bool IsQuoted(string s) + => s.Length >= 2 && s[0] == '"' && s[^1] == '"'; + + private static string EnsureBalancedQuotes(string s) + { + // If user typed starting quote but forgot closing, close it. + if (s.Length >= 1 && s[0] == '"' && (s.Length == 1 || s[^1] != '"')) + return s + "\""; + + // If user typed closing quote but no opening, open it. + if (s.Length >= 1 && s[^1] == '"' && (s.Length == 1 || s[0] != '"')) + return "\"" + s; + + // Also escape any embedded quotes (rare) + if (IsQuoted(s)) + { + var inner = s[1..^1]; + return $"\"{EscapeQuotes(inner)}\""; + } + + return s; + } + + private static string EscapeQuotes(string s) => s.Replace("\"", "\\\""); + + private static bool ContainsTopLevel(string s, char sep) + { + int depth = 0; + bool inQuotes = false; + + foreach (var ch in s) + { + if (ch == '"' && depth == 0) inQuotes = !inQuotes; + else if (!inQuotes) + { + if (ch == '(') depth++; + else if (ch == ')' && depth > 0) depth--; + else if (ch == sep && depth == 0) return true; + } + } + return false; + } + + private static List SplitTopLevel(string s, char sep) + { + var list = new List(); + var sb = new StringBuilder(); + int depth = 0; + bool inQuotes = false; + + for (int i = 0; i < s.Length; i++) + { + var ch = s[i]; + + if (ch == '"' && depth == 0) + { + inQuotes = !inQuotes; + sb.Append(ch); + continue; + } + + if (!inQuotes) + { + if (ch == '(') { depth++; sb.Append(ch); continue; } + if (ch == ')') { depth = Math.Max(0, depth - 1); sb.Append(ch); continue; } + + if (ch == sep && depth == 0) + { + // split + var piece = sb.ToString().Trim(); + if (piece.Length > 0) list.Add(piece); + sb.Clear(); + continue; + } + } + + sb.Append(ch); + } + + var tail = sb.ToString().Trim(); + if (tail.Length > 0) list.Add(tail); + return list; + } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Data/Repositories/VoiceWorks/MySqlVoiceWorkFullTextSearch.cs b/JSMR.Infrastructure/Data/Repositories/VoiceWorks/MySqlVoiceWorkFullTextSearch.cs index a923048..0b514fa 100644 --- a/JSMR.Infrastructure/Data/Repositories/VoiceWorks/MySqlVoiceWorkFullTextSearch.cs +++ b/JSMR.Infrastructure/Data/Repositories/VoiceWorks/MySqlVoiceWorkFullTextSearch.cs @@ -9,171 +9,4 @@ public class MySqlVoiceWorkFullTextSearch : IVoiceWorkFullTextSearch context.VoiceWorkSearches .Where(v => EF.Functions.Match(v.SearchText, MySqlBooleanQuery.Normalize(searchText), MySqlMatchSearchMode.Boolean) > 0) .Select(v => v.VoiceWorkId); -} - -public static class MySqlBooleanQuery -{ - // Main entry - public static string Normalize(string input) - { - if (string.IsNullOrWhiteSpace(input)) return string.Empty; - - // Split into top-level tokens by spaces (not inside quotes/parentheses) - var tokens = SplitTopLevel(input.Trim(), ' '); - var parts = new List(tokens.Count); - - foreach (var raw in tokens) - { - var t = raw.Trim(); - if (t.Length == 0) continue; - - // Preserve explicit boolean operators user may already supply - if (t[0] == '-' || t[0] == '+') - { - // Token already has a sign; normalize rest - var sign = t[0]; - var body = t.Substring(1).Trim(); - parts.Add(sign + NormalizePositive(body)); - continue; - } - - // Default: required term - parts.Add("+" + NormalizePositive(t)); - } - - return string.Join(' ', parts.Where(p => p.Length > 1 || p == "+" || p == "-")).Trim(); - } - - // Normalize a positive (non-signed) token: handles ORs, phrases, grouping - private static string NormalizePositive(string token) - { - if (string.IsNullOrWhiteSpace(token)) return string.Empty; - - // If token is a quoted phrase already -> keep quotes - if (IsQuoted(token)) - { - return EnsureBalancedQuotes(token); - } - - // If token starts/ends with parentheses, leave as-is (user grouping) - if (token.StartsWith("(") && token.EndsWith(")") && token.Length > 2) - { - // Optionally normalize inside the group if you want; - // here we trust user's grouping. - return token; - } - - // If token contains OR '|' at top level, convert to (a|b|...) - if (ContainsTopLevel(token, '|')) - { - var orParts = SplitTopLevel(token, '|') - .Select(p => NormalizeOrAtom(p.Trim())) - .Where(p => p.Length > 0); - return "(" + string.Join("|", orParts) + ")"; - } - - // Plain atom -> as-is - return token; - } - - // Normalize one OR-side atom (could be phrase or bare word) - private static string NormalizeOrAtom(string atom) - { - if (string.IsNullOrWhiteSpace(atom)) return string.Empty; - - // Allow nested quotes inside OR - if (IsQuoted(atom)) return EnsureBalancedQuotes(atom); - - // If it contains whitespace, quote it to become a phrase - if (atom.Any(char.IsWhiteSpace)) - return $"\"{EscapeQuotes(atom)}\""; - - return atom; - } - - // -------------- helpers -------------- - - private static bool IsQuoted(string s) - => s.Length >= 2 && s[0] == '"' && s[^1] == '"'; - - private static string EnsureBalancedQuotes(string s) - { - // If user typed starting quote but forgot closing, close it. - if (s.Length >= 1 && s[0] == '"' && (s.Length == 1 || s[^1] != '"')) - return s + "\""; - - // If user typed closing quote but no opening, open it. - if (s.Length >= 1 && s[^1] == '"' && (s.Length == 1 || s[0] != '"')) - return "\"" + s; - - // Also escape any embedded quotes (rare) - if (IsQuoted(s)) - { - var inner = s.Substring(1, s.Length - 2); - return $"\"{EscapeQuotes(inner)}\""; - } - - return s; - } - - private static string EscapeQuotes(string s) => s.Replace("\"", "\\\""); - - private static bool ContainsTopLevel(string s, char sep) - { - int depth = 0; - bool inQuotes = false; - - foreach (var ch in s) - { - if (ch == '"' && depth == 0) inQuotes = !inQuotes; - else if (!inQuotes) - { - if (ch == '(') depth++; - else if (ch == ')' && depth > 0) depth--; - else if (ch == sep && depth == 0) return true; - } - } - return false; - } - - private static List SplitTopLevel(string s, char sep) - { - var list = new List(); - var sb = new StringBuilder(); - int depth = 0; - bool inQuotes = false; - - for (int i = 0; i < s.Length; i++) - { - var ch = s[i]; - - if (ch == '"' && depth == 0) - { - inQuotes = !inQuotes; - sb.Append(ch); - continue; - } - - if (!inQuotes) - { - if (ch == '(') { depth++; sb.Append(ch); continue; } - if (ch == ')') { depth = Math.Max(0, depth - 1); sb.Append(ch); continue; } - - if (ch == sep && depth == 0) - { - // split - var piece = sb.ToString().Trim(); - if (piece.Length > 0) list.Add(piece); - sb.Clear(); - continue; - } - } - - sb.Append(ch); - } - - var tail = sb.ToString().Trim(); - if (tail.Length > 0) list.Add(tail); - return list; - } } \ No newline at end of file diff --git a/JSMR.Infrastructure/Http/ApiClient.cs b/JSMR.Infrastructure/Http/ApiClient.cs new file mode 100644 index 0000000..ff8b485 --- /dev/null +++ b/JSMR.Infrastructure/Http/ApiClient.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.Logging; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace JSMR.Infrastructure.Http; + +public abstract class ApiClient(HttpClient http, ILogger logger, JsonSerializerOptions? json = null) +{ + protected async Task GetJsonAsync( + string url, + Action? configureHeaders = null, + CancellationToken ct = default + ) + { + using var req = new HttpRequestMessage(HttpMethod.Get, url); + configureHeaders?.Invoke(req.Headers); + + LogRequest(req); + + using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + await EnsureSuccess(res).ConfigureAwait(false); + + var stream = await res.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + + var model = await JsonSerializer.DeserializeAsync(stream, json, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Failed to deserialize JSON to {typeof(T).Name} from {url}."); + + return model; + } + + protected async Task PostJsonAsync( + string url, + TRequest payload, + Action? configureHeaders = null, + CancellationToken ct = default) + { + var content = new StringContent(JsonSerializer.Serialize(payload, json), Encoding.UTF8, "application/json"); + using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = content }; + configureHeaders?.Invoke(req.Headers); + + LogRequest(req); + + using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + await EnsureSuccess(res).ConfigureAwait(false); + + var stream = await res.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + + var model = await JsonSerializer.DeserializeAsync(stream, json, ct).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Failed to deserialize JSON to {typeof(TResponse).Name} from {url}."); + + return model; + } + + protected virtual void LogRequest(HttpRequestMessage req) + => logger.LogDebug("HTTP {Method} {Uri}", req.Method, req.RequestUri); + + protected virtual void LogFailure(HttpResponseMessage res, string body) + => logger.LogWarning("HTTP {Status} for {Uri}. Body: {Body}", (int)res.StatusCode, res.RequestMessage?.RequestUri, Truncate(body, 500)); + + protected static string Truncate(string s, int max) => s.Length <= max ? s : s[..max] + "…"; + + protected async Task EnsureSuccess(HttpResponseMessage res) + { + if (res.IsSuccessStatusCode) return; + + string body; + try { body = await res.Content.ReadAsStringAsync().ConfigureAwait(false); } + catch { body = ""; } + + LogFailure(res, body); + + // Throw a richer exception (you can customize per API) + throw new HttpRequestException( + $"Request to {res.RequestMessage?.RequestUri} failed: {(int)res.StatusCode} {res.ReasonPhrase}. Body: {Truncate(body, 1000)}", + null, + res.StatusCode); + } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/Chobit/ChobitClient.cs b/JSMR.Infrastructure/Integrations/Chobit/ChobitClient.cs new file mode 100644 index 0000000..ec8d943 --- /dev/null +++ b/JSMR.Infrastructure/Integrations/Chobit/ChobitClient.cs @@ -0,0 +1,15 @@ +using JSMR.Application.Integrations.Chobit.Models; +using JSMR.Application.Integrations.Ports; +using JSMR.Infrastructure.Http; +using Microsoft.Extensions.Logging; + +namespace JSMR.Infrastructure.Integrations.Chobit; + +public class ChobitClient(HttpClient http, ILogger logger) : ApiClient(http, logger), IChobitClient +{ + public Task GetSampleInfoAsync(string[] productIds, CancellationToken cancellationToken = default) + { + var url = $"api/v2/dlsite/embed?workno_list=${string.Join(",", productIds)}"; + return GetJsonAsync(url, ct: cancellationToken); + } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/Chobit/Models/ChobitResponse.cs b/JSMR.Infrastructure/Integrations/Chobit/Models/ChobitResponse.cs new file mode 100644 index 0000000..d3a0d96 --- /dev/null +++ b/JSMR.Infrastructure/Integrations/Chobit/Models/ChobitResponse.cs @@ -0,0 +1,3 @@ +namespace JSMR.Infrastructure.Integrations.Chobit.Models; + +public class ChobitWorkResult : Dictionary { } \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/Chobit/Models/ChobitResult.cs b/JSMR.Infrastructure/Integrations/Chobit/Models/ChobitResult.cs new file mode 100644 index 0000000..a3e5406 --- /dev/null +++ b/JSMR.Infrastructure/Integrations/Chobit/Models/ChobitResult.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.Chobit.Models; + +public record ChobitResult +{ + [JsonPropertyName("count")] + public int Count { get; set; } + + [JsonPropertyName("works")] + public ChobitWork[] Works { get; set; } = []; +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/Chobit/Models/ChobitWork.cs b/JSMR.Infrastructure/Integrations/Chobit/Models/ChobitWork.cs new file mode 100644 index 0000000..ae894fd --- /dev/null +++ b/JSMR.Infrastructure/Integrations/Chobit/Models/ChobitWork.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.Chobit.Models; + +public record ChobitWork +{ + [JsonPropertyName("work_id")] + public string? WorkId { get; set; } + + [JsonPropertyName("dlsite_work_id")] + public string? DLSiteWorkId { get; set; } + + [JsonPropertyName("work_name")] + public string? WorkName { get; set; } + + [JsonPropertyName("work_name_kana")] + public string? WorkNameKana { get; set; } + + [JsonPropertyName("url")] + public string? Url { get; set; } + + [JsonPropertyName("embed_url")] + public string? EmbedURL { get; set; } + + [JsonPropertyName("thumb")] + public string? Thumb { get; set; } + + [JsonPropertyName("mini_thumb")] + public string? MiniThumb { get; set; } + + [JsonPropertyName("file_type")] + public string? FileType { get; set; } + + [JsonPropertyName("embed_width")] + public decimal EmbedWidth { get; set; } + + [JsonPropertyName("embed_height")] + public decimal EmbedHeight { get; set; } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/DLSiteClient.cs b/JSMR.Infrastructure/Integrations/DLSite/DLSiteClient.cs new file mode 100644 index 0000000..3524b0b --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/DLSiteClient.cs @@ -0,0 +1,28 @@ +using JSMR.Application.Integrations.DLSite.Models; +using JSMR.Application.Integrations.Ports; +using JSMR.Infrastructure.Http; +using JSMR.Infrastructure.Integrations.DLSite.Mapping; +using JSMR.Infrastructure.Integrations.DLSite.Models; +using Microsoft.Extensions.Logging; + +namespace JSMR.Infrastructure.Integrations.DLSite; + +public class DLSiteClient(HttpClient http, ILogger logger) : ApiClient(http, logger), IDLSiteClient +{ + public async Task GetVoiceWorkDetailsAsync(string[] productIds, CancellationToken cancellationToken = default) + { + if (productIds.Length == 0) + throw new Exception("No products to get product information."); + + string productIdCollection = string.Join(",", productIds.Where(x => !string.IsNullOrWhiteSpace(x))); + + if (string.IsNullOrWhiteSpace(productIdCollection)) + throw new Exception("Invalid product id(s)."); + + string url = $"maniax/product/info/ajax?product_id={productIdCollection}&cdn_cache_min=1"; + + var productInfoCollection = await GetJsonAsync(url, ct: cancellationToken); + + return DLSiteToDomainMapper.Map(productInfoCollection); + } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/DLSiteClientRegistration.cs b/JSMR.Infrastructure/Integrations/DLSite/DLSiteClientRegistration.cs new file mode 100644 index 0000000..dc372d1 --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/DLSiteClientRegistration.cs @@ -0,0 +1,27 @@ +using JSMR.Application.Integrations.Ports; +using JSMR.Infrastructure.Integrations.DLSite.Serialization; +using Microsoft.Extensions.DependencyInjection; + +namespace JSMR.Infrastructure.Integrations.DLSite; + +public static class DLSiteClientRegistration +{ + //public static IServiceCollection AddDLSiteClient(this IServiceCollection services, Uri baseAddress) + //{ + // // Build per-client JSON options with the DLSite-specific converters + // var json = DLSiteJsonContext.CreateOptions(); + + // services.AddHttpClient(client => + // { + // client.BaseAddress = baseAddress; // e.g., new Uri("https://www.dlsite.com/") + // client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + // client.Timeout = TimeSpan.FromSeconds(20); + // }) + // .AddPolicyHandler(ResiliencePolicies.DefaultRetry()); // optional Polly + + // // Provide the options to the client via DI + // services.AddSingleton(json); + + // return services; + //} +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Mapping/DLSiteToDomainMapper.cs b/JSMR.Infrastructure/Integrations/DLSite/Mapping/DLSiteToDomainMapper.cs new file mode 100644 index 0000000..6a83977 --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Mapping/DLSiteToDomainMapper.cs @@ -0,0 +1,131 @@ +using JSMR.Application.Common; +using JSMR.Application.Integrations.DLSite.Models; +using JSMR.Infrastructure.Integrations.DLSite.Models; + +namespace JSMR.Infrastructure.Integrations.DLSite.Mapping; + +public static class DLSiteToDomainMapper +{ + private const string OptTrial = "TRI"; + private const string OptDLPlay = "DLP"; + private const string OptReviews = "REV"; + private const string OptOfficialTranslation = "DOT"; + + private const string OptAIFull = "AIG"; + private const string OptAIPartial = "AIP"; + + private static readonly (string Code, Language Lang)[] SupportedLanguageFlags = + [ + ("JPN", Language.Japanese), + ("ENG", Language.English), + ("CHI", Language.ChineseTraditional), + ("CHI_HANT", Language.ChineseTraditional), + ("CHI_HANS", Language.ChineseSimplified) + ]; + + private static readonly Dictionary TranslationLanguageMap = + SupportedLanguageFlags.ToDictionary(x => x.Code, x => x.Lang, StringComparer.OrdinalIgnoreCase); + + public static VoiceWorkDetailCollection Map(ProductInfoCollection? productInfoCollection) + { + VoiceWorkDetailCollection result = []; + + if (productInfoCollection is null) + return result; + + foreach (var keyValue in productInfoCollection) + { + string productId = keyValue.Key; + ProductInfo productInfo = keyValue.Value; + + VoiceWorkDetails voiceWorkDetails = Map(productInfo); + result[productId] = voiceWorkDetails; + } + + return result; + } + + public static VoiceWorkDetails Map(ProductInfo productInfo) + { + ArgumentNullException.ThrowIfNull(productInfo); + + string[] options = [.. productInfo.Options.Where(s => !string.IsNullOrWhiteSpace(s))]; + HashSet optionsSet = new(options, StringComparer.OrdinalIgnoreCase); + + return new VoiceWorkDetails + { + Series = MapSeries(productInfo), + Translation = MapTranslation(productInfo, optionsSet), + WishlistCount = productInfo.WishlistCount, + DownloadCount = productInfo.DownloadCount, + RegistrationDate = productInfo.RegistrationDate, + SupportedLanguages = MapSupportedLanguages(optionsSet), + AI = MapAIGeneration(optionsSet), + HasTrial = optionsSet.Contains(OptTrial), + HasDLPlay = optionsSet.Contains(OptDLPlay), + HasReviews = optionsSet.Contains(OptReviews) + }; + } + + private static VoiceWorkSeries? MapSeries(ProductInfo productInfo) + { + if (string.IsNullOrWhiteSpace(productInfo.TitleId) || string.IsNullOrWhiteSpace(productInfo.TitleName)) + return null; + + return new VoiceWorkSeries + { + Identifier = productInfo.TitleId, + Name = productInfo.TitleName + }; + } + + private static VoiceWorkTranslation? MapTranslation(ProductInfo info, HashSet options) + { + ProductTranslationInfo? translationInfo = info.TranslationInfo; + + if (translationInfo == null || string.IsNullOrWhiteSpace(translationInfo.OriginalWorkNumber) || string.IsNullOrWhiteSpace(translationInfo.Language)) + return null; + + string languageCode = translationInfo.Language.Trim(); + + if (!options.Contains(languageCode)) + return null; + + if (!TranslationLanguageMap.TryGetValue(languageCode, out Language language)) + return null; + + string originalId = translationInfo.OriginalWorkNumber; + bool isOfficial = options.Contains(OptOfficialTranslation); + + return new VoiceWorkTranslation + { + OriginalProductId = originalId, + Language = language, + IsOfficialTranslation = isOfficial + }; + } + + private static Language[] MapSupportedLanguages(HashSet options) + { + List languages = []; + + foreach (var (code, language) in SupportedLanguageFlags) + { + if (options.Contains(code) && !languages.Contains(language)) + languages.Add(language); + } + + return [.. languages]; + } + + private static AIGeneration MapAIGeneration(HashSet options) + { + if (options.Contains(OptAIFull)) + return AIGeneration.Full; + + if (options.Contains(OptAIPartial)) + return AIGeneration.Partial; + + return AIGeneration.None; + } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Models/DownloadCountItem.cs b/JSMR.Infrastructure/Integrations/DLSite/Models/DownloadCountItem.cs new file mode 100644 index 0000000..a5942d5 --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Models/DownloadCountItem.cs @@ -0,0 +1,32 @@ +using JSMR.Infrastructure.Integrations.DLSite.Serialization; +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Models; + +public class DownloadCountItem +{ + [JsonPropertyName("workno")] + public string? WorkNumber { get; set; } + + [JsonPropertyName("edition_id")] + public int EditionId { get; set; } + + [JsonPropertyName("edition_type")] + public string? EditionType { get; set; } + + [JsonPropertyName("display_order")] + public int DisplayOrder { get; set; } + + [JsonPropertyName("label")] + public string? Label { get; set; } + + [JsonPropertyName("lang")] + public string? Language { get; set; } + + [JsonPropertyName("dl_count")] + [JsonConverter(typeof(NumberConverter))] + public int DownloadCount { get; set; } + + [JsonPropertyName("display_label")] + public string? DisplayLabel { get; set; } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Models/ProductBonus.cs b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductBonus.cs new file mode 100644 index 0000000..842848e --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductBonus.cs @@ -0,0 +1,23 @@ +using JSMR.Infrastructure.Integrations.DLSite.Serialization; +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Models; + +public class ProductBonus +{ + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("dist_flg")] + public string? DistributionFlag { get; set; } + + [JsonPropertyName("end_date")] + [JsonConverter(typeof(NullableDateTimeConverterUsingDateTimeParse))] + public DateTime? EndDate { get; set; } + + [JsonPropertyName("end_date_str")] + public string? EndDateString { get; set; } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Models/ProductInfo.cs b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductInfo.cs new file mode 100644 index 0000000..c5167bb --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductInfo.cs @@ -0,0 +1,245 @@ +using JSMR.Infrastructure.Integrations.DLSite.Serialization; +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Models; + +public class ProductInfo +{ + [JsonPropertyName("site_id")] + public string? SiteId { get; set; } + + [JsonPropertyName("site_id_touch")] + public string? SiteIdTouch { get; set; } + + [JsonPropertyName("maker_id")] + public string? MakerId { get; set; } + + [JsonPropertyName("age_category")] + public int AgeCategory { get; set; } + + [JsonPropertyName("affiliate_deny")] + public int AffiliateDeny { get; set; } + + [JsonPropertyName("dl_count")] + [JsonConverter(typeof(NumberConverter))] + public int DownloadCount { get; set; } + + // This value can be a number or "false" + [JsonPropertyName("wishlist_count")] + [JsonConverter(typeof(NumberConverter))] + public int WishlistCount { get; set; } + + [JsonPropertyName("dl_format")] + public int DownloadFormat { get; set; } + + [JsonPropertyName("rank")] + public ProductRank[] Rank { get; set; } = []; + + [JsonPropertyName("rate_average")] + public int? RateAverage { get; set; } + + [JsonPropertyName("rate_average_2dp")] + public decimal? RateAverage2DP { get; set; } + + [JsonPropertyName("rate_average_star")] + public int? RateAverageStar { get; set; } + + [JsonPropertyName("rate_count")] + public int? RateCount { get; set; } + + [JsonPropertyName("rate_count_detail")] + public ProductRateCountDetail[] RateCountDetail { get; set; } = []; + + [JsonPropertyName("review_count")] + [JsonConverter(typeof(NumberConverter))] + public int ReviewCount { get; set; } + + [JsonPropertyName("price")] + public int? Price { get; set; } + + [JsonPropertyName("price_without_tax")] + public int? PriceWithoutTax { get; set; } + + [JsonPropertyName("price_str")] + public string? PriceString { get; set; } + + [JsonPropertyName("default_point_rate")] + public int? DefaultPointRate { get; set; } + + [JsonPropertyName("default_point")] + public int DefaultPoint { get; set; } + + [JsonPropertyName("product_point_rate")] + public int? ProductPointRate { get; set; } + + [JsonPropertyName("dlsiteplay_work")] + public bool DLSitePlayWork { get; set; } + + [JsonPropertyName("is_sale")] + public bool IsSale { get; set; } + + [JsonPropertyName("on_sale")] + public int OnSale { get; set; } + + [JsonPropertyName("is_discount")] + public bool IsDiscount { get; set; } + + [JsonPropertyName("is_pointup")] + public bool IsPointUp { get; set; } + + //[JsonPropertyName("gift")] + //[JsonConverter(typeof(ListConverter))] + //public List Gift { get; set; } + + [JsonPropertyName("is_rental")] + public bool IsRental { get; set; } + + [JsonPropertyName("work_rentals")] + public string[] WorkRentals { get; set; } = []; + + [JsonPropertyName("upgrade_min_price")] + public int UpgradeMinPrice { get; set; } + + [JsonPropertyName("down_url")] + public string? DownloadURL { get; set; } + + [JsonPropertyName("is_tartget")] + public string? IsTargetGet { get; set; } + + [JsonPropertyName("title_id")] + public string? TitleId { get; set; } + + [JsonPropertyName("title_name")] + public string? TitleName { get; set; } + + [JsonPropertyName("title_volumn")] + public int? TitleVolumeNumber { get; set; } + + [JsonPropertyName("title_work_count")] + public int? TitleWorkCount { get; set; } + + [JsonPropertyName("is_title_completed")] + public bool IsTitleCompleted { get; set; } + + [JsonPropertyName("bulkbuy_key")] + public string? BulkBuyKey { get; set; } + + [JsonPropertyName("bonuses")] + public ProductBonus[] Bonuses { get; set; } = []; + + [JsonPropertyName("is_limit_work")] + public bool IsLimitWork { get; set; } + + [JsonPropertyName("is_sold_out")] + public bool IsSoldOut { get; set; } + + [JsonPropertyName("limit_stock")] + public int LimitStock { get; set; } + + [JsonPropertyName("is_reserve_work")] + public bool IsReserveWork { get; set; } + + [JsonPropertyName("is_reservable")] + public bool IsReservable { get; set; } + + [JsonPropertyName("is_timesale")] + public bool IsTimeSale { get; set; } + + [JsonPropertyName("timesale_stock")] + public int TimeSaleStock { get; set; } + + [JsonPropertyName("is_free")] + public bool IsFree { get; set; } + + [JsonPropertyName("is_oly")] + public bool IsOLY { get; set; } + + [JsonPropertyName("is_led")] + public bool IsLED { get; set; } + + [JsonPropertyName("is_wcc")] + public bool IsWCC { get; set; } + + [JsonPropertyName("translation_info")] + public ProductTranslationInfo? TranslationInfo { get; set; } + + [JsonPropertyName("work_name")] + public string? WorkName { get; set; } + + [JsonPropertyName("work_image")] + public string? WorkImage { get; set; } + + [JsonPropertyName("sales_end_info")] + public string? SalesEndInfo { get; set; } + + [JsonPropertyName("voice_pack")] + public string? VoicePack { get; set; } + + [JsonPropertyName("regist_date")] + [JsonConverter(typeof(NullableDateTimeConverterUsingDateTimeParse))] + public DateTime? RegistrationDate { get; set; } + + [JsonPropertyName("locale_price")] + [JsonConverter(typeof(DictionaryConverter))] + public Dictionary LocalePrice { get; set; } = []; + + [JsonPropertyName("locale_price_str")] + [JsonConverter(typeof(DictionaryConverter))] + public Dictionary LocalePriceString { get; set; } = []; + + [JsonPropertyName("work_type")] + public string? WorkType { get; set; } + + [JsonPropertyName("book_type")] + public string? BookType { get; set; } + + [JsonPropertyName("is_pack_work")] + public bool IsPackWork { get; set; } + + [JsonPropertyName("limited_free_terms")] + public string[] LimitedFreeTerms { get; set; } = []; + + [JsonPropertyName("dl_count_total")] + public int DownloadCountTotal { get; set; } + + [JsonPropertyName("dl_count_items")] + public DownloadCountItem[] DownloadCountItems { get; set; } = []; + + [JsonPropertyName("discount_rate")] + public int? DiscountRate { get; set; } + + [JsonPropertyName("official_price")] + public int? OfficialPrice { get; set; } + + // Don't know the type for sure + [JsonPropertyName("campaign_id")] + public string? CampaignId { get; set; } + + [JsonPropertyName("official_price_str")] + public string? OfficialPriceString { get; set; } + + [JsonPropertyName("locale_official_price")] + [JsonConverter(typeof(DictionaryConverter))] + public Dictionary LocaleOfficialPrice { get; set; } = []; + + [JsonPropertyName("locale_official_price_str")] + [JsonConverter(typeof(DictionaryConverter))] + public Dictionary LocaleOfficialPriceString { get; set; } = []; + + // TODO: Convert to date + [JsonPropertyName("discount_end_date")] + public string? DiscountEndDate { get; set; } + + [JsonPropertyName("discount_to")] + public string? DiscountTo { get; set; } + + [JsonPropertyName("discount_caption")] + public string? DiscountCaption { get; set; } + + [JsonPropertyName("default_point_str")] + public string? DefaultPointString { get; set; } + + [JsonPropertyName("options")] + [JsonConverter(typeof(ProductInfoOptionsConverter))] + public string[] Options { get; set; } = []; +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Models/ProductInfoCollection.cs b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductInfoCollection.cs new file mode 100644 index 0000000..0384b5d --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductInfoCollection.cs @@ -0,0 +1,6 @@ +namespace JSMR.Infrastructure.Integrations.DLSite.Models; + +public class ProductInfoCollection : Dictionary +{ + +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Models/ProductRank.cs b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductRank.cs new file mode 100644 index 0000000..49884e7 --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductRank.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Models; + +public class ProductRank +{ + [JsonPropertyName("term")] + public string? Term { get; set; } + + [JsonPropertyName("category")] + public string? Category { get; set; } + + [JsonPropertyName("rank")] + public int Rank { get; set; } + + [JsonPropertyName("rank_date")] + public string? RankDate { get; set; } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Models/ProductRateCountDetail.cs b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductRateCountDetail.cs new file mode 100644 index 0000000..8cca640 --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductRateCountDetail.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Models; + +public class ProductRateCountDetail +{ + [JsonPropertyName("review_point")] + public int ReviewPoint { get; set; } + + [JsonPropertyName("count")] + public int Count { get; set; } + + [JsonPropertyName("ratio")] + public int Ratio { get; set; } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Models/ProductReview.cs b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductReview.cs new file mode 100644 index 0000000..e1f0854 --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductReview.cs @@ -0,0 +1,81 @@ +using JSMR.Infrastructure.Integrations.DLSite.Serialization; +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Models; + +public class ProductReview +{ + [JsonPropertyName("member_review_id")] + public string? MemberReviewId { get; set; } + + [JsonPropertyName("workno")] + public string? WorkNumber { get; set; } + + [JsonPropertyName("reviewer_id")] + public string? ReviewerId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("recommend")] + public string? Recommend { get; set; } + + [JsonPropertyName("spoiler")] + public string? Spoiler { get; set; } + + [JsonPropertyName("review_title")] + public string? ReviewTitle { get; set; } + + [JsonPropertyName("review_text")] + public string? ReviewText { get; set; } + + [JsonPropertyName("entry_date")] + [JsonConverter(typeof(DateTimeConverterUsingDateTimeParse))] + public DateTime EntryDate { get; set; } + + [JsonPropertyName("regist_date")] + [JsonConverter(typeof(DateTimeConverterUsingDateTimeParse))] + public DateTime RegistrationDate { get; set; } + + [JsonPropertyName("good_review")] + public string? GoodReview { get; set; } + + [JsonPropertyName("bad_review")] + public string? BadReview { get; set; } + + [JsonPropertyName("circle_id")] + public string? CircleId { get; set; } + + [JsonPropertyName("nick_name")] + public string? Nickname { get; set; } + + [JsonPropertyName("popularity")] + public string? Popularity { get; set; } + + [JsonPropertyName("rate")] + public string? Rate { get; set; } + + [JsonPropertyName("circle_name")] + public string? CircleName { get; set; } + + [JsonPropertyName("reviewer_status")] + public string? ReviewerStatus { get; set; } + + [JsonPropertyName("is_purchased")] + public string? IsPurchased { get; set; } + + [JsonPropertyName("pickup")] + public bool Pickup { get; set; } + + [JsonPropertyName("rate_num")] + public string? RateNumber { get; set; } + + [JsonPropertyName("reviewer_rank")] + public string? ReviewerRank { get; set; } + + [JsonPropertyName("longtext")] + public bool LongText { get; set; } + + [JsonPropertyName("genre")] + public Dictionary Genre { get; set; } = []; +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Models/ProductReviewCollection.cs b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductReviewCollection.cs new file mode 100644 index 0000000..434b85c --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductReviewCollection.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Models; + +public class ProductReviewCollection +{ + [JsonPropertyName("is_success")] + public bool IsSuccess { get; set; } + + [JsonPropertyName("error_msg")] + public string? ErrorMessage { get; set; } + + [JsonPropertyName("product_id")] + public string? ProductId { get; set; } + + [JsonPropertyName("product_name")] + public string? ProductName { get; set; } + + [JsonPropertyName("is_reserve")] + public bool IsReserve { get; set; } + + [JsonPropertyName("review_deny")] + public bool ReviewDeny { get; set; } + + [JsonPropertyName("mix_pickup")] + public bool MixPickup { get; set; } + + [JsonPropertyName("order")] + public string? Order { get; set; } + + [JsonPropertyName("limit")] + public string? Limit { get; set; } + + [JsonPropertyName("page")] + public int Page { get; set; } + + [JsonPropertyName("review_list")] + public ProductReview[] ReviewList { get; set; } = []; + + [JsonPropertyName("count")] + public string? Count { get; set; } + + [JsonPropertyName("reviewer_genre_list")] + public ProductReviewerGenre[] ReviewerGenreList { get; set; } = []; +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Models/ProductReviewerGenre.cs b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductReviewerGenre.cs new file mode 100644 index 0000000..d44c6a2 --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductReviewerGenre.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Models; + +public class ProductReviewerGenre +{ + [JsonPropertyName("genre")] + public string? Genre { get; set; } + + [JsonPropertyName("genre_count")] + public string? GenreCount { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Models/ProductTranslationInfo.cs b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductTranslationInfo.cs new file mode 100644 index 0000000..95bd49c --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Models/ProductTranslationInfo.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Models; + +public class ProductTranslationInfo +{ + [JsonPropertyName("is_translation_agree")] + public bool IsTranslationAgree { get; set; } + + [JsonPropertyName("is_volunteer")] + public bool IsVolunteer { get; set; } + + [JsonPropertyName("is_original")] + public bool IsOriginal { get; set; } + + [JsonPropertyName("is_parent")] + public bool IsParent { get; set; } + + [JsonPropertyName("is_child")] + public bool IsChild { get; set; } + + [JsonPropertyName("original_workno")] + public string? OriginalWorkNumber { get; set; } + + [JsonPropertyName("parent_workno")] + public string? ParentWorkNumber { get; set; } + + [JsonPropertyName("child_worknos")] + public string[] ChildWorkNumbers { get; set; } = []; + + [JsonPropertyName("lang")] + public string? Language { get; set; } + + [JsonPropertyName("production_trade_price_rate")] + public int ProductionTradePriceRate { get; set; } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Models/RelatedProductSeries.cs b/JSMR.Infrastructure/Integrations/DLSite/Models/RelatedProductSeries.cs new file mode 100644 index 0000000..811958f --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Models/RelatedProductSeries.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Models; + +public class RelatedProductSeries +{ + [JsonPropertyName("data")] + public RelatedProductSeriesData? Data { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("msg")] + public string? Message { get; set; } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Models/RelatedProductSeriesData.cs b/JSMR.Infrastructure/Integrations/DLSite/Models/RelatedProductSeriesData.cs new file mode 100644 index 0000000..9e57c29 --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Models/RelatedProductSeriesData.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Models; + +public class RelatedProductSeriesData +{ + [JsonPropertyName("worknos")] + public string[] WorkNumbers { get; set; } = []; +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Serialization/DLSiteJsonContext.cs b/JSMR.Infrastructure/Integrations/DLSite/Serialization/DLSiteJsonContext.cs new file mode 100644 index 0000000..51f969d --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Serialization/DLSiteJsonContext.cs @@ -0,0 +1,23 @@ +using JSMR.Infrastructure.Integrations.DLSite.Models; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Serialization; + +[JsonSerializable(typeof(ProductInfoCollection))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +public partial class DLSiteJsonContext : JsonSerializerContext +{ + public static JsonSerializerOptions CreateOptions() + { + JsonSerializerOptions options = new(JsonSerializerDefaults.Web); + options.Converters.Add(new NumberConverter()); + options.Converters.Add(new DictionaryConverter()); + options.Converters.Add(new DictionaryConverter()); + options.Converters.Add(new DateTimeConverterUsingDateTimeParse()); + options.Converters.Add(new NullableDateTimeConverterUsingDateTimeParse()); + options.Converters.Add(new ProductInfoOptionsConverter()); + + return options; + } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Serialization/DateTimeConverterUsingDateTimeParse.cs b/JSMR.Infrastructure/Integrations/DLSite/Serialization/DateTimeConverterUsingDateTimeParse.cs new file mode 100644 index 0000000..727148f --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Serialization/DateTimeConverterUsingDateTimeParse.cs @@ -0,0 +1,17 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Serialization; + +public class DateTimeConverterUsingDateTimeParse : JsonConverter +{ + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return DateTime.Parse(reader.GetString() ?? string.Empty); + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString() ?? string.Empty); + } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Serialization/DictionaryConverter.cs b/JSMR.Infrastructure/Integrations/DLSite/Serialization/DictionaryConverter.cs new file mode 100644 index 0000000..6a510d8 --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Serialization/DictionaryConverter.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Serialization; + +public sealed class DictionaryConverter : JsonConverter> where TKey : notnull +{ + public override Dictionary? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => JsonSerializer.Deserialize>(ref reader, options); + + public override void Write(Utf8JsonWriter writer, Dictionary value, JsonSerializerOptions options) + => JsonSerializer.Serialize(writer, value, options); +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Serialization/NullableDateTimeConverterUsingDateTimeParse.cs b/JSMR.Infrastructure/Integrations/DLSite/Serialization/NullableDateTimeConverterUsingDateTimeParse.cs new file mode 100644 index 0000000..58bc7dd --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Serialization/NullableDateTimeConverterUsingDateTimeParse.cs @@ -0,0 +1,35 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Serialization; + +public sealed class NullableDateTimeConverterUsingDateTimeParse : JsonConverter +{ + public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null) + return null; + + if (reader.TokenType is JsonTokenType.String) + { + var s = reader.GetString(); + + if (string.IsNullOrWhiteSpace(s)) + return null; + + if (DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var dt)) + return dt; + } + + return null; + } + + public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) + { + if (value is DateTime dt) + writer.WriteStringValue(dt); + else + writer.WriteNullValue(); + } +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Serialization/NumberConverter.cs b/JSMR.Infrastructure/Integrations/DLSite/Serialization/NumberConverter.cs new file mode 100644 index 0000000..1b162d7 --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Serialization/NumberConverter.cs @@ -0,0 +1,23 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Serialization; + +public sealed class NumberConverter : JsonConverter +{ + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.Number => reader.TryGetInt32(out var i) ? i : 0, + JsonTokenType.String => int.TryParse(reader.GetString(), out var j) ? j : 0, + JsonTokenType.False => 0, + JsonTokenType.True => 1, + JsonTokenType.Null => 0, + _ => 0 + }; + } + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + => writer.WriteNumberValue(value); +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Integrations/DLSite/Serialization/ProductInfoOptionsConverter.cs b/JSMR.Infrastructure/Integrations/DLSite/Serialization/ProductInfoOptionsConverter.cs new file mode 100644 index 0000000..0bfa260 --- /dev/null +++ b/JSMR.Infrastructure/Integrations/DLSite/Serialization/ProductInfoOptionsConverter.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JSMR.Infrastructure.Integrations.DLSite.Serialization; + +public sealed class ProductInfoOptionsConverter : JsonConverter +{ + public override string[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string value = reader.GetString() ?? string.Empty; + + if (string.IsNullOrEmpty(value)) + return []; + + return [.. value.Split('#')]; + } + + public override void Write(Utf8JsonWriter writer, string[] value, JsonSerializerOptions options) + { + writer.WriteStringValue(string.Join("#", value)); + } +} \ No newline at end of file diff --git a/JSMR.Tests/Integration/VoiceWorkSearchProviderTests.cs b/JSMR.Tests/Integration/VoiceWorkSearchProviderTests.cs index 242d487..0c7490f 100644 --- a/JSMR.Tests/Integration/VoiceWorkSearchProviderTests.cs +++ b/JSMR.Tests/Integration/VoiceWorkSearchProviderTests.cs @@ -9,7 +9,7 @@ namespace JSMR.Tests.Integration; public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture) : IClassFixture { - private VoiceWorkSearchProvider InitializeVoiceWorkSearchProvider(AppDbContext context) + private static VoiceWorkSearchProvider InitializeVoiceWorkSearchProvider(AppDbContext context) { MySqlVoiceWorkFullTextSearch fullTextSearch = new(); VoiceWorkSearchProvider provider = new(context, fullTextSearch); diff --git a/JSMR.Tests/Integrations/DLSite/DLSiteClientTests.cs b/JSMR.Tests/Integrations/DLSite/DLSiteClientTests.cs new file mode 100644 index 0000000..31e23a2 --- /dev/null +++ b/JSMR.Tests/Integrations/DLSite/DLSiteClientTests.cs @@ -0,0 +1,86 @@ +using JSMR.Application.Integrations.DLSite.Models; +using JSMR.Infrastructure.Integrations.DLSite; +using JSMR.Infrastructure.Integrations.DLSite.Mapping; +using JSMR.Infrastructure.Integrations.DLSite.Models; +using JSMR.Infrastructure.Integrations.DLSite.Serialization; +using JSMR.Tests.Utilities; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using System.Net; +using System.Net.Http; +using System.Text.Json; + +namespace JSMR.Tests.Integrations.DLSite; + +public class DLSiteClientTests +{ + private static async Task ReadJsonResourceAsync(string resourceName) + { + return await ResourceHelper.ReadAsync($"JSMR.Tests.Integrations.DLSite.{resourceName}"); + } + + [Fact] + public async Task Deserialize_Product_Info_Collection() + { + string productInfoJson = await ReadJsonResourceAsync("Product-Info.json"); + + HttpResponseMessage response = new() + { + Content = new StringContent(productInfoJson), + StatusCode = HttpStatusCode.OK + }; + + HttpClient httpClient = Substitute.For(); + httpClient.BaseAddress = new Uri("https://fake.site.com/"); + + //{ BaseAddress = new Uri("https://www.dlsite.com/") }; + + httpClient.SendAsync(Arg.Any(), Arg.Any(), CancellationToken.None) + .Returns(Task.FromResult(response)); + + var logger = Substitute.For>(); + var client = new DLSiteClient(httpClient, logger); + + var result = await client.GetVoiceWorkDetailsAsync(["RJ01230163"], CancellationToken.None); + + result.Count.ShouldBe(1); + } + + [Fact] + public void Map_Basic_ProductInfoCollection() + { + ProductInfoCollection productInfoCollection = new() + { + { + "RG0001", + new ProductInfo() + { + WishlistCount = 250, + DownloadCount = 100, + Options = ["TRI", "DLP", "JPN"], + TitleId = "SE0001", + TitleName = "Series 1" + } + } + }; + + VoiceWorkDetailCollection mappedCollection = DLSiteToDomainMapper.Map(productInfoCollection); + mappedCollection.Count.ShouldBe(1); + mappedCollection.ShouldContainKey("RG0001"); + + VoiceWorkDetails voiceWorkDetails = mappedCollection["RG0001"]; + voiceWorkDetails.WishlistCount.ShouldBe(250); + voiceWorkDetails.DownloadCount.ShouldBe(100); + voiceWorkDetails.HasTrial.ShouldBe(true); + voiceWorkDetails.HasDLPlay.ShouldBe(true); + voiceWorkDetails.AI.ShouldBe(Application.Common.AIGeneration.None); + + voiceWorkDetails.Series.ShouldNotBeNull(); + voiceWorkDetails.Series.Identifier.ShouldBe("SE0001"); + voiceWorkDetails.Series.Name.ShouldBe("Series 1"); + + voiceWorkDetails.SupportedLanguages.Length.ShouldBe(1); + voiceWorkDetails.SupportedLanguages[0].ShouldBe(Application.Common.Language.Japanese); + } +} \ No newline at end of file diff --git a/JSMR.Tests/Integrations/DLSite/Product-Info.json b/JSMR.Tests/Integrations/DLSite/Product-Info.json new file mode 100644 index 0000000..3dc9cbb --- /dev/null +++ b/JSMR.Tests/Integrations/DLSite/Product-Info.json @@ -0,0 +1,223 @@ +{ + "RJ01230163": { + "site_id": "home", + "site_id_touch": "hometouch", + "maker_id": "RG64308", + "age_category": 1, + "affiliate_deny": 0, + "dl_count": 659, + "wishlist_count": 380, + "dl_format": 0, + "rank": [ + { + "term": "day", + "category": "voice", + "rank": 20, + "rank_date": "2024-07-21" + }, + { + "term": "week", + "category": "voice", + "rank": 46, + "rank_date": "2024-07-26" + } + ], + "rate_average": 5, + "rate_average_2dp": 4.92, + "rate_average_star": 50, + "rate_count": 37, + "rate_count_detail": [ + { + "review_point": 1, + "count": 0, + "ratio": 0 + }, + { + "review_point": 2, + "count": 0, + "ratio": 0 + }, + { + "review_point": 3, + "count": 0, + "ratio": 0 + }, + { + "review_point": 4, + "count": 3, + "ratio": 8 + }, + { + "review_point": 5, + "count": 34, + "ratio": 91 + } + ], + "review_count": 1, + "price": 1650, + "price_without_tax": 1500, + "price_str": "1,650", + "default_point_rate": 10, + "default_point": 150, + "product_point_rate": null, + "dlsiteplay_work": true, + "is_ana": false, + "is_sale": true, + "on_sale": 1, + "is_discount": false, + "is_pointup": false, + "gift": [], + "is_rental": false, + "work_rentals": [], + "upgrade_min_price": 110, + "down_url": "https:\/\/www.dlsite.com\/maniax\/download\/split\/=\/product_id\/RJ01230163.html", + "is_tartget": "blank", + "title_id": "SRI0000032800", + "title_name": "Commander Pampering Team!", + "title_name_masked": "Commander Pampering Team!", + "title_volumn": null, + "title_work_count": 21, + "is_title_completed": false, + "bulkbuy_key": null, + "bonuses": [], + "is_limit_work": false, + "is_sold_out": false, + "limit_stock": 0, + "is_reserve_work": false, + "is_reservable": false, + "is_timesale": false, + "timesale_stock": 0, + "is_free": false, + "is_oly": true, + "is_led": false, + "is_noreduction": false, + "is_wcc": false, + "translation_info": { + "is_translation_agree": false, + "is_volunteer": false, + "is_original": true, + "is_parent": false, + "is_child": false, + "is_translation_bonus_child": false, + "original_workno": null, + "parent_workno": null, + "child_worknos": [], + "lang": null, + "production_trade_price_rate": 0, + "translation_bonus_langs": [] + }, + "work_name": "[Azur Lane ASMR] Commander Pampering Team! Golden Hind's Tentacular Treatment", + "work_name_masked": "[Azur Lane ASMR] Commander Pampering Team! Golden Hind's Tentacular Treatment", + "work_image": "\/\/img.dlsite.jp\/modpub\/images2\/work\/doujin\/RJ01231000\/RJ01230163_img_main.jpg", + "sales_end_info": null, + "voice_pack": null, + "regist_date": "2024-07-20 00:00:00", + "locale_price": { + "en_US": 11.13, + "ar_AE": 11.13, + "es_ES": 9.55, + "de_DE": 9.55, + "fr_FR": 9.55, + "it_IT": 9.55, + "pt_BR": 9.55, + "zh_TW": 342, + "zh_CN": 79.45, + "ko_KR": 15464, + "id_ID": 182744, + "vi_VN": 292657, + "th_TH": 359.49, + "sv_SE": 105.03 + }, + "locale_price_str": { + "en_US": "$11.13 USD<\/i>", + "ar_AE": "$11.13 USD<\/i>", + "es_ES": "9,55 \u20ac<\/i>", + "de_DE": "9,55 \u20ac<\/i>", + "fr_FR": "9,55 \u20ac<\/i>", + "it_IT": "9,55 \u20ac<\/i>", + "pt_BR": "9,55 \u20ac<\/i>", + "zh_TW": "NT$<\/i>342", + "zh_CN": "79.45 RMB<\/i>", + "ko_KR": "15,464 \u20a9<\/i>", + "id_ID": "Rp <\/i>182.744", + "vi_VN": "292.657\u20ab<\/i>", + "th_TH": "\u0e3f<\/i>359.49", + "sv_SE": "105,03 kr<\/i>" + }, + "currency_price": { + "JPY": 1650, + "USD": 11.13364080175709, + "EUR": 9.549655604692873, + "GBP": 8.290615726443848, + "TWD": 341.92639257294434, + "CNY": 79.4453271703019, + "KRW": 15463.917525773195, + "IDR": 182744.4899767416, + "VND": 292656.9705569351, + "THB": 359.4927883567911, + "SEK": 105.03265560747069, + "HKD": 86.85627655038454, + "SGD": 14.338300468210683, + "CAD": 15.361798280037203, + "MYR": 47.01310098414091, + "BRL": 60.70282838390676, + "AUD": 17.02427971820174, + "PHP": 637.7058050552678, + "MXN": 208.2912542920622, + "NZD": 18.956888588642414, + "INR": 979.5191451469278 + }, + "work_type": "SOU", + "book_type": null, + "discount_calc_type": null, + "is_pack_work": false, + "limited_free_terms": [], + "official_price": 1650, + "options": "SND#TRI#DLP#ENG#REV", + "custom_genres": [ "dlsiteaward2024" ], + "dl_count_total": 12524, + "dl_count_items": [ + { + "workno": "RJ01230156", + "edition_id": 25712, + "edition_type": "language", + "display_order": 1, + "label": "\u65e5\u672c\u8a9e", + "lang": "JPN", + "dl_count": "10529", + "display_label": "Japanese" + }, + { + "workno": "RJ01230163", + "edition_id": 25712, + "edition_type": "language", + "display_order": 2, + "label": "\u82f1\u8a9e", + "lang": "ENG", + "dl_count": "659", + "display_label": "English" + }, + { + "workno": "RJ01230165", + "edition_id": 25712, + "edition_type": "language", + "display_order": 3, + "label": "\u7c21\u4f53\u4e2d\u6587", + "lang": "CHI_HANS", + "dl_count": "811", + "display_label": "Chinese Simplified" + }, + { + "workno": "RJ01230167", + "edition_id": 25712, + "edition_type": "language", + "display_order": 4, + "label": "\u7e41\u4f53\u4e2d\u6587", + "lang": "CHI_HANT", + "dl_count": "525", + "display_label": "Chinese Traditional" + } + ], + "default_point_str": "150" + } +} \ No newline at end of file diff --git a/JSMR.Tests/JSMR.Tests.csproj b/JSMR.Tests/JSMR.Tests.csproj index ad29a4b..fcc96f5 100644 --- a/JSMR.Tests/JSMR.Tests.csproj +++ b/JSMR.Tests/JSMR.Tests.csproj @@ -7,12 +7,21 @@ false + + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/JSMR.Tests/Unit/MySqlBooleanQueryTests.cs b/JSMR.Tests/Unit/MySqlBooleanQueryTests.cs new file mode 100644 index 0000000..832ee2c --- /dev/null +++ b/JSMR.Tests/Unit/MySqlBooleanQueryTests.cs @@ -0,0 +1,31 @@ +using JSMR.Infrastructure.Data.Repositories.VoiceWorks; +using Shouldly; + +namespace JSMR.Tests.Unit; + +public class MySqlBooleanQueryTests +{ + [Fact] + public void Normalize_Basic_Usage() + { + string normalizedValue = MySqlBooleanQuery.Normalize("value1 value2|value3 -value4 \"value 5\""); + + normalizedValue.ShouldBe("+value1 +(value2|value3) -value4 +\"value 5\""); + } + + [Fact] + public void Normalize_Unusual_Usage() + { + string normalizedValue = MySqlBooleanQuery.Normalize("+value1 +(value2|value3) -value4 +\"value 5\""); + + normalizedValue.ShouldBe("+value1 +(value2|value3) -value4 +\"value 5\""); + } + + [Fact] + public void Normalize_Bad_Data() + { + string normalizedValue = MySqlBooleanQuery.Normalize("value1 + value2 - value3"); + + normalizedValue.ShouldBe("+value1 +value2 +value3"); + } +} diff --git a/JSMR.Tests/Utilities/ResourceHelper.cs b/JSMR.Tests/Utilities/ResourceHelper.cs new file mode 100644 index 0000000..5f14a89 --- /dev/null +++ b/JSMR.Tests/Utilities/ResourceHelper.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using System.Text; + +namespace JSMR.Tests.Utilities; + +public static class ResourceHelper +{ + /// + /// Reads an embedded resource from the calling assembly. + /// + /// The full resource name, e.g. "MyNamespace.Folder.sample.json". + public static async Task ReadAsync(string resourceName) + { + Assembly assmbly = Assembly.GetExecutingAssembly(); + + using Stream? stream = assmbly.GetManifestResourceStream(resourceName) + ?? throw new FileNotFoundException($"Resource '{resourceName}' not found."); + + using StreamReader reader = new(stream, Encoding.UTF8); + + return await reader.ReadToEndAsync(); + } +} \ No newline at end of file