Implemented DLSiteClient.
This commit is contained in:
@@ -45,10 +45,10 @@ Content-Type: {{contentType}}
|
|||||||
{
|
{
|
||||||
"options": {
|
"options": {
|
||||||
"criteria": {
|
"criteria": {
|
||||||
"keywords": "tsundere maid"
|
"keywords": "tsundere"
|
||||||
},
|
},
|
||||||
"pageNumber": 1,
|
"pageNumber": 1,
|
||||||
"pageSize": 20,
|
"pageSize": 100,
|
||||||
"sortOptions": [
|
"sortOptions": [
|
||||||
{ "field": "Downloads", "direction": "Descending" }
|
{ "field": "Downloads", "direction": "Descending" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ builder.Services
|
|||||||
|
|
||||||
// DbContext (MySQL here; swap to Npgsql when you migrate)
|
// DbContext (MySQL here; swap to Npgsql when you migrate)
|
||||||
var cs = builder.Configuration.GetConnectionString("AppDb")
|
var cs = builder.Configuration.GetConnectionString("AppDb")
|
||||||
?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb");
|
?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb2");
|
||||||
|
|
||||||
builder.Services.AddDbContext<AppDbContext>(opt =>
|
builder.Services.AddDbContext<AppDbContext>(opt =>
|
||||||
opt.UseMySql(cs, ServerVersion.AutoDetect(cs))
|
opt.UseMySql(cs, ServerVersion.AutoDetect(cs))
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ public class VoiceWorkDetails
|
|||||||
public int DownloadCount { get; init; }
|
public int DownloadCount { get; init; }
|
||||||
public DateTime? RegistrationDate { get; init; }
|
public DateTime? RegistrationDate { get; init; }
|
||||||
public Language[] SupportedLanguages { get; init; } = [];
|
public Language[] SupportedLanguages { get; init; } = [];
|
||||||
//public AIGeneration AI { get; init; }
|
public AIGeneration AI { get; init; }
|
||||||
public bool HasTrial { get; init; }
|
public bool HasTrial { get; init; }
|
||||||
|
public bool HasDLPlay { get; init; }
|
||||||
public bool HasReviews { get; init; }
|
public bool HasReviews { get; init; }
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ using JSMR.Application.Circles.Queries.Search;
|
|||||||
using JSMR.Application.Common.Caching;
|
using JSMR.Application.Common.Caching;
|
||||||
using JSMR.Application.Creators.Ports;
|
using JSMR.Application.Creators.Ports;
|
||||||
using JSMR.Application.Creators.Queries.Search.Ports;
|
using JSMR.Application.Creators.Queries.Search.Ports;
|
||||||
|
using JSMR.Application.Integrations.Ports;
|
||||||
using JSMR.Application.Tags.Ports;
|
using JSMR.Application.Tags.Ports;
|
||||||
using JSMR.Application.Tags.Queries.Search.Ports;
|
using JSMR.Application.Tags.Queries.Search.Ports;
|
||||||
using JSMR.Application.VoiceWorks.Queries.Search;
|
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.Creators;
|
||||||
using JSMR.Infrastructure.Data.Repositories.Tags;
|
using JSMR.Infrastructure.Data.Repositories.Tags;
|
||||||
using JSMR.Infrastructure.Data.Repositories.VoiceWorks;
|
using JSMR.Infrastructure.Data.Repositories.VoiceWorks;
|
||||||
|
using JSMR.Infrastructure.Integrations.DLSite;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace JSMR.Infrastructure.DI;
|
namespace JSMR.Infrastructure.DI;
|
||||||
@@ -37,4 +39,27 @@ public static class InfrastructureServiceCollectionExtensions
|
|||||||
|
|
||||||
return services;
|
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<IDLSiteClient, DLSiteClient>(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;
|
||||||
|
//}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ public sealed class VoiceWorkSearchConfiguration : IEntityTypeConfiguration<Voic
|
|||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<VoiceWorkSearch> builder)
|
public void Configure(EntityTypeBuilder<VoiceWorkSearch> builder)
|
||||||
{
|
{
|
||||||
builder.ToTable("VoiceWorkSearches");
|
builder.ToTable("VoiceWorkSearches2");
|
||||||
builder.HasKey(x => x.VoiceWorkId); // also the FK
|
builder.HasKey(x => x.VoiceWorkId); // also the FK
|
||||||
|
|
||||||
builder.Property(x => x.SearchText).IsRequired(); // TEXT/LONGTEXT
|
builder.Property(x => x.SearchText).IsRequired(); // TEXT/LONGTEXT
|
||||||
|
|||||||
@@ -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<string>(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<string> SplitTopLevel(string s, char sep)
|
||||||
|
{
|
||||||
|
var list = new List<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,171 +9,4 @@ public class MySqlVoiceWorkFullTextSearch : IVoiceWorkFullTextSearch
|
|||||||
context.VoiceWorkSearches
|
context.VoiceWorkSearches
|
||||||
.Where(v => EF.Functions.Match(v.SearchText, MySqlBooleanQuery.Normalize(searchText), MySqlMatchSearchMode.Boolean) > 0)
|
.Where(v => EF.Functions.Match(v.SearchText, MySqlBooleanQuery.Normalize(searchText), MySqlMatchSearchMode.Boolean) > 0)
|
||||||
.Select(v => v.VoiceWorkId);
|
.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<string>(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<string> SplitTopLevel(string s, char sep)
|
|
||||||
{
|
|
||||||
var list = new List<string>();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
79
JSMR.Infrastructure/Http/ApiClient.cs
Normal file
79
JSMR.Infrastructure/Http/ApiClient.cs
Normal file
@@ -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<T> GetJsonAsync<T>(
|
||||||
|
string url,
|
||||||
|
Action<HttpRequestHeaders>? 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<T>(stream, json, ct).ConfigureAwait(false)
|
||||||
|
?? throw new InvalidOperationException($"Failed to deserialize JSON to {typeof(T).Name} from {url}.");
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task<TResponse> PostJsonAsync<TRequest, TResponse>(
|
||||||
|
string url,
|
||||||
|
TRequest payload,
|
||||||
|
Action<HttpRequestHeaders>? 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<TResponse>(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 = "<unable to read 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
JSMR.Infrastructure/Integrations/Chobit/ChobitClient.cs
Normal file
15
JSMR.Infrastructure/Integrations/Chobit/ChobitClient.cs
Normal file
@@ -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<ChobitWorkResult> GetSampleInfoAsync(string[] productIds, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var url = $"api/v2/dlsite/embed?workno_list=${string.Join(",", productIds)}";
|
||||||
|
return GetJsonAsync<ChobitWorkResult>(url, ct: cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace JSMR.Infrastructure.Integrations.Chobit.Models;
|
||||||
|
|
||||||
|
public class ChobitWorkResult : Dictionary<string, ChobitResult> { }
|
||||||
@@ -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; } = [];
|
||||||
|
}
|
||||||
39
JSMR.Infrastructure/Integrations/Chobit/Models/ChobitWork.cs
Normal file
39
JSMR.Infrastructure/Integrations/Chobit/Models/ChobitWork.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
28
JSMR.Infrastructure/Integrations/DLSite/DLSiteClient.cs
Normal file
28
JSMR.Infrastructure/Integrations/DLSite/DLSiteClient.cs
Normal file
@@ -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<VoiceWorkDetailCollection> 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<ProductInfoCollection>(url, ct: cancellationToken);
|
||||||
|
|
||||||
|
return DLSiteToDomainMapper.Map(productInfoCollection);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IDLSiteClient, DLSiteClient>(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;
|
||||||
|
//}
|
||||||
|
}
|
||||||
@@ -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<string, Language> 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<string> 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<string> 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<string> options)
|
||||||
|
{
|
||||||
|
List<Language> languages = [];
|
||||||
|
|
||||||
|
foreach (var (code, language) in SupportedLanguageFlags)
|
||||||
|
{
|
||||||
|
if (options.Contains(code) && !languages.Contains(language))
|
||||||
|
languages.Add(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. languages];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AIGeneration MapAIGeneration(HashSet<string> options)
|
||||||
|
{
|
||||||
|
if (options.Contains(OptAIFull))
|
||||||
|
return AIGeneration.Full;
|
||||||
|
|
||||||
|
if (options.Contains(OptAIPartial))
|
||||||
|
return AIGeneration.Partial;
|
||||||
|
|
||||||
|
return AIGeneration.None;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
245
JSMR.Infrastructure/Integrations/DLSite/Models/ProductInfo.cs
Normal file
245
JSMR.Infrastructure/Integrations/DLSite/Models/ProductInfo.cs
Normal file
@@ -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<string>))]
|
||||||
|
//public List<string> 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<string, decimal>))]
|
||||||
|
public Dictionary<string, decimal> LocalePrice { get; set; } = [];
|
||||||
|
|
||||||
|
[JsonPropertyName("locale_price_str")]
|
||||||
|
[JsonConverter(typeof(DictionaryConverter<string, string>))]
|
||||||
|
public Dictionary<string, string> 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<string, decimal>))]
|
||||||
|
public Dictionary<string, decimal> LocaleOfficialPrice { get; set; } = [];
|
||||||
|
|
||||||
|
[JsonPropertyName("locale_official_price_str")]
|
||||||
|
[JsonConverter(typeof(DictionaryConverter<string, string>))]
|
||||||
|
public Dictionary<string, string> 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; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace JSMR.Infrastructure.Integrations.DLSite.Models;
|
||||||
|
|
||||||
|
public class ProductInfoCollection : Dictionary<string, ProductInfo>
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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<string, string> Genre { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -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; } = [];
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; } = [];
|
||||||
|
}
|
||||||
@@ -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<string, decimal>());
|
||||||
|
options.Converters.Add(new DictionaryConverter<string, string>());
|
||||||
|
options.Converters.Add(new DateTimeConverterUsingDateTimeParse());
|
||||||
|
options.Converters.Add(new NullableDateTimeConverterUsingDateTimeParse());
|
||||||
|
options.Converters.Add(new ProductInfoOptionsConverter());
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace JSMR.Infrastructure.Integrations.DLSite.Serialization;
|
||||||
|
|
||||||
|
public class DateTimeConverterUsingDateTimeParse : JsonConverter<DateTime>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace JSMR.Infrastructure.Integrations.DLSite.Serialization;
|
||||||
|
|
||||||
|
public sealed class DictionaryConverter<TKey, TValue> : JsonConverter<Dictionary<TKey, TValue>> where TKey : notnull
|
||||||
|
{
|
||||||
|
public override Dictionary<TKey, TValue>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
=> JsonSerializer.Deserialize<Dictionary<TKey, TValue>>(ref reader, options);
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, Dictionary<TKey, TValue> value, JsonSerializerOptions options)
|
||||||
|
=> JsonSerializer.Serialize(writer, value, options);
|
||||||
|
}
|
||||||
@@ -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<DateTime?>
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace JSMR.Infrastructure.Integrations.DLSite.Serialization;
|
||||||
|
|
||||||
|
public sealed class NumberConverter : JsonConverter<int>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace JSMR.Infrastructure.Integrations.DLSite.Serialization;
|
||||||
|
|
||||||
|
public sealed class ProductInfoOptionsConverter : JsonConverter<string[]>
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ namespace JSMR.Tests.Integration;
|
|||||||
|
|
||||||
public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture) : IClassFixture<VoiceWorkSearchProviderFixture>
|
public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture) : IClassFixture<VoiceWorkSearchProviderFixture>
|
||||||
{
|
{
|
||||||
private VoiceWorkSearchProvider InitializeVoiceWorkSearchProvider(AppDbContext context)
|
private static VoiceWorkSearchProvider InitializeVoiceWorkSearchProvider(AppDbContext context)
|
||||||
{
|
{
|
||||||
MySqlVoiceWorkFullTextSearch fullTextSearch = new();
|
MySqlVoiceWorkFullTextSearch fullTextSearch = new();
|
||||||
VoiceWorkSearchProvider provider = new(context, fullTextSearch);
|
VoiceWorkSearchProvider provider = new(context, fullTextSearch);
|
||||||
|
|||||||
86
JSMR.Tests/Integrations/DLSite/DLSiteClientTests.cs
Normal file
86
JSMR.Tests/Integrations/DLSite/DLSiteClientTests.cs
Normal file
@@ -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<string> 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>();
|
||||||
|
httpClient.BaseAddress = new Uri("https://fake.site.com/");
|
||||||
|
|
||||||
|
//{ BaseAddress = new Uri("https://www.dlsite.com/") };
|
||||||
|
|
||||||
|
httpClient.SendAsync(Arg.Any<HttpRequestMessage>(), Arg.Any<HttpCompletionOption>(), CancellationToken.None)
|
||||||
|
.Returns(Task.FromResult(response));
|
||||||
|
|
||||||
|
var logger = Substitute.For<ILogger<DLSiteClient>>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
223
JSMR.Tests/Integrations/DLSite/Product-Info.json
Normal file
223
JSMR.Tests/Integrations/DLSite/Product-Info.json
Normal file
@@ -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<i> USD<\/i>",
|
||||||
|
"ar_AE": "$11.13<i> USD<\/i>",
|
||||||
|
"es_ES": "9,55<i> \u20ac<\/i>",
|
||||||
|
"de_DE": "9,55<i> \u20ac<\/i>",
|
||||||
|
"fr_FR": "9,55<i> \u20ac<\/i>",
|
||||||
|
"it_IT": "9,55<i> \u20ac<\/i>",
|
||||||
|
"pt_BR": "9,55<i> \u20ac<\/i>",
|
||||||
|
"zh_TW": "<i>NT$<\/i>342",
|
||||||
|
"zh_CN": "79.45<i> RMB<\/i>",
|
||||||
|
"ko_KR": "15,464<i> \u20a9<\/i>",
|
||||||
|
"id_ID": "<i>Rp <\/i>182.744",
|
||||||
|
"vi_VN": "292.657<i>\u20ab<\/i>",
|
||||||
|
"th_TH": "<i>\u0e3f<\/i>359.49",
|
||||||
|
"sv_SE": "105,03<i> 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,21 @@
|
|||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Remove="Integrations\DLSite\Product-Info.json" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Integrations\DLSite\Product-Info.json" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||||
<PackageReference Include="Testcontainers" Version="4.7.0" />
|
<PackageReference Include="Testcontainers" Version="4.7.0" />
|
||||||
|
|||||||
31
JSMR.Tests/Unit/MySqlBooleanQueryTests.cs
Normal file
31
JSMR.Tests/Unit/MySqlBooleanQueryTests.cs
Normal file
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
23
JSMR.Tests/Utilities/ResourceHelper.cs
Normal file
23
JSMR.Tests/Utilities/ResourceHelper.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace JSMR.Tests.Utilities;
|
||||||
|
|
||||||
|
public static class ResourceHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Reads an embedded resource from the calling assembly.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="resourceName">The full resource name, e.g. "MyNamespace.Folder.sample.json".</param>
|
||||||
|
public static async Task<string> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user