diff --git a/OakArchive/Services/MemoryCacheService.cs b/OakArchive/Services/MemoryCacheService.cs new file mode 100644 index 0000000..2e1d627 --- /dev/null +++ b/OakArchive/Services/MemoryCacheService.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace OakArchive.Services; + +public interface IMemoryCacheService +{ + Task GetOrCreateAsync(string cacheKey, Func> createItem, TimeSpan? expiration = null) + where T : class; + + void Remove(string cacheKey); +} + +public class MemoryCacheService : IMemoryCacheService +{ + private readonly IMemoryCache _memoryCache; + private readonly TimeSpan _defaultExpiration = TimeSpan.FromHours(1); + + public MemoryCacheService(IMemoryCache memoryCache) + { + _memoryCache = memoryCache; + } + + public async Task GetOrCreateAsync(string cacheKey, Func> createItem, TimeSpan? expiration = null) + where T : class + { + if (_memoryCache.TryGetValue(cacheKey, out T? cachedItem)) + { + return cachedItem; + } + + var item = await createItem(); + + if (item == null) + { + return null; + } + + var cacheOptions = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(expiration ?? _defaultExpiration); + + _memoryCache.Set(cacheKey, item, cacheOptions); + + return item!; + } + + public void Remove(string cacheKey) + { + _memoryCache.Remove(cacheKey); + } +} \ No newline at end of file diff --git a/OakArchive/Services/TcgDexDataUpdater.cs b/OakArchive/Services/TcgDexDataUpdater.cs new file mode 100644 index 0000000..56f5541 --- /dev/null +++ b/OakArchive/Services/TcgDexDataUpdater.cs @@ -0,0 +1,261 @@ +using Microsoft.EntityFrameworkCore; +using OakArchive.Database; +using OakArchive.Entities.CardMarket; +using OakArchive.Entities.Cards; +using OakArchive.Entities.Enums; +using OakArchive.Entities.TCGdex; +using OakArchive.Util; +using OakArchive.Util.Extensions; +using Card = OakArchive.Entities.Cards.Card; +using CardCount = OakArchive.Entities.Set.CardCount; +using Legal = OakArchive.Entities.Set.Legal; +using Set = OakArchive.Entities.Set.Set; + +namespace OakArchive.Services; + +public interface ITcgDexDataUpdater +{ + public Task SyncBaseDataFromTcgDex(); + + public Task + UpdateCard(string set, int id, string language); +} + +public class TcgDexDataUpdater : ITcgDexDataUpdater +{ + private readonly ITcgDexService _tcgDexService; + private readonly ApplicationDbContext _dbContext; + + private readonly List _languages = Enum.GetValues().ToList(); + + public TcgDexDataUpdater(ITcgDexService tcgDexService, ApplicationDbContext dbContext) + { + _tcgDexService = tcgDexService; + _dbContext = dbContext; + } + + public async Task SyncBaseDataFromTcgDex() + { + var hasChanges = false; + int i = 0; + foreach (var language in _languages) + { + var apiSeriesBriefs = await _tcgDexService.GetSeries(language.ToApiCode()); + + await Task.Delay(500); + + foreach (var serieBrief in apiSeriesBriefs) + { + if (i == 1) return true; + i += 1; + if (string.IsNullOrEmpty(serieBrief.Id)) continue; + + var dbSerie = await EnsureSerieExistsAsync(serieBrief, language); + + var fullApiSerie = await _tcgDexService.GetSetsOfSeries(serieBrief.Id, language.ToApiCode()); + if (fullApiSerie?.Sets == null) continue; + + await Task.Delay(300); + + foreach (var setBrief in fullApiSerie.Sets) + { + if (string.IsNullOrEmpty(setBrief.Id)) continue; + + var isSetInDb = dbSerie.Sets.Any(s => s.SetInfo?.SetId == setBrief.Id); + if (isSetInDb) continue; + + var fullApiSet = await _tcgDexService.GetSet(setBrief.Id, language.ToApiCode()); + if (fullApiSet == null) continue; + + var dbSet = await BuildSetEntityWithCardsAsync(setBrief, fullApiSet, language); + + dbSerie.Sets.Add(dbSet); + + await Task.Delay(500); + } + + if (await _dbContext.SaveChangesAsync() > 0) + { + hasChanges = true; + } + } + } + + return hasChanges; + } + + public async Task UpdateCard(string set, int id, string language) + { + var tcgDexCardId = $"{set.ToLower()}-{id}"; + if (!Enum.TryParse(language, true, out var langEnum)) + { + langEnum = Language.En; + } + + var dbCard = await _dbContext.Cards + .Include(c => c.TcgDexCardInfo) + .Include(c => c.Attacks) + .Include(c => c.Weaknesses) + .FirstOrDefaultAsync(c => c.TcgDexCardInfo.FullId == tcgDexCardId && c.Set.Language == langEnum); + + if (dbCard == null) + { + throw new KeyNotFoundException( + $"Die Basis-Karte mit der TCGdex-ID '{tcgDexCardId}' wurde für die Sprache '{language}' nicht in der Datenbank gefunden. Führe zuerst den Basis-Sync aus."); + } + + var apiCard = await _tcgDexService.GetCard(set, id, language); + if (apiCard == null) + { + throw new HttpRequestException( + $"Die Karte '{tcgDexCardId}' konnte von der TCGdex-API nicht geladen werden."); + } + + dbCard.Name = apiCard.Name ?? dbCard.Name; + dbCard.Hp = apiCard.Hp; + dbCard.Retreat = apiCard.Retreat; + dbCard.Illustrator = apiCard.Illustrator; + dbCard.EvolveFrom = apiCard.EvolveFrom; + dbCard.Description = apiCard.Description; + dbCard.RegulationMark = apiCard.RegulationMark; + dbCard.Types = apiCard.Types; + + if (!string.IsNullOrEmpty(apiCard.Rarity) && + Enum.TryParse(apiCard.Rarity.Replace(" ", ""), true, out var parsedRarity)) + { + dbCard.Rarity = parsedRarity; + } + + if (!string.IsNullOrEmpty(apiCard.Category) && + Enum.TryParse(apiCard.Category, true, out var parsedCategory)) + { + dbCard.Category = parsedCategory; + } + + dbCard.Variants = MapApiVariantsToEnumList(apiCard.Variants); + + dbCard.Attacks.Clear(); + if (apiCard.Attacks != null) + { + foreach (var apiAttack in apiCard.Attacks) + { + dbCard.Attacks.Add(new Attack + { + Name = apiAttack.Name ?? "Unknown", + Damage = apiAttack.Damage, + Effect = apiAttack.Effect ?? string.Empty, + Cost = apiAttack.Cost ?? [] + }); + } + } + + dbCard.Weaknesses.Clear(); + if (apiCard.Weaknesses != null) + { + foreach (var apiWeakness in apiCard.Weaknesses) + { + dbCard.Weaknesses.Add(new Weakness + { + Type = apiWeakness.Type ?? "Unknown", + Value = apiWeakness.Value ?? string.Empty + }); + } + } + + await _dbContext.SaveChangesAsync(); + + return dbCard; + } + + private List MapApiVariantsToEnumList(Models.TcgDex.Variants? apiVariants) + { + var list = new List(); + if (apiVariants == null) return list; + + if (apiVariants.FirstEdition == true) list.Add(Variant.FirstEdition); + if (apiVariants.Holo == true) list.Add(Variant.Holo); + if (apiVariants.Normal == true) list.Add(Variant.Normal); + if (apiVariants.Reverse == true) list.Add(Variant.Reverse); + if (apiVariants.WPromo == true) list.Add(Variant.WPromo); + + return list; + } + + private async Task EnsureSerieExistsAsync(Models.TcgDex.SerieBrief brief, Language language) + { + var dbSerie = await _dbContext.Series + .Include(s => s.Sets) + .ThenInclude(s => s.SetInfo) + .FirstOrDefaultAsync(s => s.TcgDexSeriesInfo.SeriesId == brief.Id && s.Language == language); + + if (dbSerie != null) return dbSerie; + + dbSerie = new Entities.Serie.Serie + { + Language = language, + Name = brief.Name ?? "Unknown", + Logo = brief.Logo, + TcgDexSeriesInfo = new TcgDexSeriesInfo { SeriesId = brief.Id! } + }; + _dbContext.Series.Add(dbSerie); + + return dbSerie; + } + + private async Task BuildSetEntityWithCardsAsync(Models.TcgDex.SetBrief setBrief, Models.TcgDex.Set fullApiSet, + Language language) + { + DateTime? utcReleaseDate = fullApiSet.ReleaseDate != null + ? DateTime.SpecifyKind(fullApiSet.ReleaseDate, DateTimeKind.Utc) + : null; + + var dbSet = new Set + { + Language = language, + Name = setBrief.Name ?? "Unknown", + Logo = setBrief.Logo, + ReleaseDate = utcReleaseDate, + Symbol = setBrief.Symbol, + SetInfo = new TcgDexSetInfo { SetId = fullApiSet.Id }, + CardMarketSetInfo = new CardMarketSetInfo { ExpansionId = "0" }, // TODO: Späterer Price-Scraper + Legal = new Legal + { + Expanded = fullApiSet.Legal?.Expanded ?? false, + Standard = fullApiSet.Legal?.Standard ?? false + }, + CardCount = new CardCount + { + FirstEd = fullApiSet.CardCount?.FirstEd ?? 0, + Holo = fullApiSet.CardCount?.Holo ?? 0, + Normal = fullApiSet.CardCount?.Normal ?? 0, + Official = fullApiSet.CardCount?.Official ?? 0, + Reverse = fullApiSet.CardCount?.Reverse ?? 0, + Total = fullApiSet.CardCount?.Total ?? 0 + } + }; + + if (fullApiSet.Cards is not { Count: > 0 }) return dbSet; + + var cardTasks = fullApiSet.Cards.Select(async c => + { + var localPath = await _tcgDexService.DownloadCardImage(c, language.ToApiCode()); + + return new Card + { + Name = c.Name ?? "Unknown", + Image = localPath == null ? c.Image : localPath.Item1, + CardNumber = c.LocalId ?? 0, + PHash = localPath == null ? 0 : PerceptualHashHelper.GenerateHash(localPath.Item2), + TcgDexCardInfo = new TcgDexCardInfo + { + FullId = c.Id ?? string.Empty, + LocalId = c.LocalId ?? 0 + } + }; + }); + + dbSet.Cards = (await Task.WhenAll(cardTasks)).ToList(); + + return dbSet; + } +} \ No newline at end of file diff --git a/OakArchive/Services/TcgDexService.cs b/OakArchive/Services/TcgDexService.cs new file mode 100644 index 0000000..8541784 --- /dev/null +++ b/OakArchive/Services/TcgDexService.cs @@ -0,0 +1,113 @@ +using OakArchive.Models.TcgDex; + +namespace OakArchive.Services; + +public interface ITcgDexService +{ + public Task> GetSeries(string language); + + public Task GetSetsOfSeries(string series, string language); + + public Task GetSet(string set, string language); + + public Task GetCard(string set, int id, string language); + + public Task?> DownloadCardImage(Card card, string language); +} + +public class TcgDexService : ITcgDexService +{ + private readonly HttpClient _httpClient; + private readonly IMemoryCacheService _cacheService; + + private readonly string _baseFolder = Path.Combine(Directory.GetCurrentDirectory(), "Cards"); + private readonly SemaphoreSlim _downloadSemaphore = new SemaphoreSlim(3, 3); + + public TcgDexService(HttpClient httpClient, IMemoryCacheService cacheService) + { + _httpClient = httpClient; + _cacheService = cacheService; + } + + public async Task> GetSeries(string language) + { + var cacheKey = $"tcgdex_series_{language.ToLower()}"; + + async Task> FetchFromApi() + { + var res = await _httpClient.GetFromJsonAsync>($"{language}/series"); + return res ?? []; + } + + return await _cacheService.GetOrCreateAsync(cacheKey, FetchFromApi, TimeSpan.FromDays(7)); + } + + public async Task GetSetsOfSeries(string series, string language) + { + var cacheKey = $"tcgdex_serie_{series.ToLower()}_{language.ToLower()}"; + + async Task FetchFromApi() + { + var res = await _httpClient.GetFromJsonAsync($"{language}/series/{series}"); + return res; + } + + return await _cacheService.GetOrCreateAsync(cacheKey, FetchFromApi, TimeSpan.FromDays(7)); + } + + public async Task GetSet(string set, string language) + { + var cacheKey = $"tcgdex_set_{set.ToLower()}_{language.ToLower()}"; + + async Task FetchFromApi() + { + var res = await _httpClient.GetFromJsonAsync($"{language}/sets/{set}"); + return res; + } + + return await _cacheService.GetOrCreateAsync(cacheKey, FetchFromApi, TimeSpan.FromDays(7)); + } + + public async Task GetCard(string set, int id, string language) + { + var cacheKey = $"tcgdex_card_{set.ToLower()}_{language.ToLower()}"; + + async Task FetchFromApi() + { + var res = await _httpClient.GetFromJsonAsync($"{language}/cards/{set}-{id}"); + return res; + } + + return await _cacheService.GetOrCreateAsync(cacheKey, FetchFromApi, TimeSpan.FromDays(7)); + } + + public async Task?> DownloadCardImage(Card card, string language) + { + var split = card.Id!.Split("-"); + var filePath = Path.Combine(_baseFolder, split[0], language,$"{split[1]}.webp"); + + if (File.Exists(filePath)) + { + var res = await File.ReadAllBytesAsync(filePath); + return Tuple.Create(filePath, res); + } + + await _downloadSemaphore.WaitAsync(); + + try + { + var res = await _httpClient.GetByteArrayAsync(card.Image + "/high.webp"); + + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + await File.WriteAllBytesAsync(filePath, res); + + await Task.Delay(200); + + return Tuple.Create(filePath, res); + } + finally + { + _downloadSemaphore.Release(); + } + } +} \ No newline at end of file