using BlueLaminate.EFCore.Data; using BlueLaminate.EFCore.Entities; using BlueLaminate.Scraper.Skins; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace BlueLaminate.Core.Skins; /// /// Loads the CS2 skin catalogue from the CSGO-API dataset and upserts it. The /// weapon list and the collections/containers are derived from the skins /// themselves, so any that are missing are created on the fly and no skin is /// dropped. Throttled to once a month unless forced, since the catalogue changes /// slowly. /// public sealed class SkinSyncService { public const string Source = "skins"; private readonly SkinTrackerDbContext _db; private readonly SkinCatalogClient _client; private readonly ILogger _logger; public SkinSyncService( SkinTrackerDbContext db, SkinCatalogClient client, ILogger logger) { _db = db; _client = client; _logger = logger; } public async Task SyncAsync(bool force = false, CancellationToken ct = default) { var now = DateTimeOffset.UtcNow; var lastRanAt = await _db.ScrapeRuns .Where(r => r.Source == Source) .OrderByDescending(r => r.RanAt) .Select(r => (DateTimeOffset?)r.RanAt) .FirstOrDefaultAsync(ct); if (!force && lastRanAt is { } last && last.AddMonths(1) > now) { _logger.LogInformation( "Skipping skin sync; last run was {LastRanAt:u} (throttled to monthly).", last); return new SkinSyncResult(true, last, 0, 0, 0, 0, 0); } _logger.LogInformation("Starting skin sync (force: {Force}).", force); var catalog = await _client.FetchAsync(ct); _logger.LogInformation("Loaded {Count} skins from the catalogue.", catalog.Count); var weapons = await _db.Weapons.ToDictionaryAsync(w => w.Name, ct); var collections = await _db.Collections.ToDictionaryAsync(c => c.Slug, ct); var existing = await _db.Skins .Include(s => s.Collections) .ToDictionaryAsync(s => s.Slug, ct); var inserted = 0; var updated = 0; var weaponsCreated = 0; var collectionsCreated = 0; foreach (var s in catalog) { var weapon = ResolveWeapon(weapons, s, ref weaponsCreated); var sources = ResolveCollections(collections, s, ref collectionsCreated); if (existing.TryGetValue(s.Id, out var skin)) { if (Apply(skin, s, weapon, sources)) { updated++; } } else { skin = new Skin { Slug = s.Id }; Apply(skin, s, weapon, sources); _db.Skins.Add(skin); existing[s.Id] = skin; inserted++; } } _db.ScrapeRuns.Add(new ScrapeRun { Source = Source, RanAt = now, ItemCount = catalog.Count }); await _db.SaveChangesAsync(ct); _logger.LogInformation( "Skin sync complete: {Loaded} loaded, {Inserted} inserted, {Updated} updated, " + "{WeaponsCreated} weapons created, {CollectionsCreated} collections created.", catalog.Count, inserted, updated, weaponsCreated, collectionsCreated); return new SkinSyncResult( false, lastRanAt, catalog.Count, inserted, updated, weaponsCreated, collectionsCreated); } private Weapon ResolveWeapon(Dictionary weapons, CatalogSkin s, ref int created) { if (weapons.TryGetValue(s.WeaponName, out var weapon)) { // Category/team can be refined as the catalogue grows; keep them current. weapon.Type = s.Category; weapon.Team = s.Team; return weapon; } weapon = new Weapon { Name = s.WeaponName, Type = s.Category, Team = s.Team }; _db.Weapons.Add(weapon); weapons[s.WeaponName] = weapon; created++; return weapon; } private List ResolveCollections( Dictionary collections, CatalogSkin s, ref int created) { var resolved = new List(s.Sources.Count); foreach (var source in s.Sources) { if (!collections.TryGetValue(source.Id, out var collection)) { collection = new Collection { Slug = source.Id, Name = source.Name, Type = source.Type }; _db.Collections.Add(collection); collections[source.Id] = collection; created++; } resolved.Add(collection); } return resolved; } // Copies catalogue values onto the entity. Returns true if anything changed. // The weapon navigation is assigned directly (a newly created weapon has no // id yet to compare against, so reference-assigning is the only correct way // to wire the FK). The collection links are reconciled against the current set. private static bool Apply(Skin skin, CatalogSkin s, Weapon weapon, List sources) { skin.Weapon = weapon; var changed = false; void Set(Func get, Action set, T value) { if (!EqualityComparer.Default.Equals(get(), value)) { set(value); changed = true; } } Set(() => skin.Name, v => skin.Name = v, s.Name); Set(() => skin.DefIndex, v => skin.DefIndex = v, s.DefIndex); Set(() => skin.PaintIndex, v => skin.PaintIndex = v, s.PaintIndex); Set(() => skin.Rarity, v => skin.Rarity = v, s.Rarity); Set(() => skin.Description, v => skin.Description = v, s.Description); Set(() => skin.ImageUrl, v => skin.ImageUrl = v, s.ImageUrl); Set(() => skin.StatTrakAvailable, v => skin.StatTrakAvailable = v, s.StatTrakAvailable); Set(() => skin.SouvenirAvailable, v => skin.SouvenirAvailable = v, s.SouvenirAvailable); Set(() => skin.FloatMin, v => skin.FloatMin = v, s.FloatMin); Set(() => skin.FloatMax, v => skin.FloatMax = v, s.FloatMax); if (ReconcileCollections(skin.Collections, sources)) { changed = true; } return changed; } // Adds collections the skin newly belongs to and removes ones it no longer // does, comparing by slug. Returns true if the set changed. private static bool ReconcileCollections(ICollection current, List desired) { var changed = false; foreach (var collection in desired) { if (!current.Any(c => c.Slug == collection.Slug)) { current.Add(collection); changed = true; } } foreach (var collection in current.Where(c => desired.All(d => d.Slug != c.Slug)).ToList()) { current.Remove(collection); changed = true; } return changed; } }