Implemented DLSiteClient.

This commit is contained in:
2025-09-08 17:08:47 -04:00
parent 429252e61f
commit f250276a99
39 changed files with 1584 additions and 173 deletions

View File

@@ -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<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;
//}
}

View File

@@ -8,7 +8,7 @@ public sealed class VoiceWorkSearchConfiguration : IEntityTypeConfiguration<Voic
{
public void Configure(EntityTypeBuilder<VoiceWorkSearch> builder)
{
builder.ToTable("VoiceWorkSearches");
builder.ToTable("VoiceWorkSearches2");
builder.HasKey(x => x.VoiceWorkId); // also the FK
builder.Property(x => x.SearchText).IsRequired(); // TEXT/LONGTEXT

View File

@@ -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;
}
}

View File

@@ -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<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;
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -0,0 +1,3 @@
namespace JSMR.Infrastructure.Integrations.Chobit.Models;
public class ChobitWorkResult : Dictionary<string, ChobitResult> { }

View File

@@ -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; } = [];
}

View 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; }
}

View 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);
}
}

View File

@@ -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;
//}
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View 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; } = [];
}

View File

@@ -0,0 +1,6 @@
namespace JSMR.Infrastructure.Integrations.DLSite.Models;
public class ProductInfoCollection : Dictionary<string, ProductInfo>
{
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; } = [];
}

View File

@@ -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; } = [];
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; } = [];
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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));
}
}