202 lines
7.2 KiB
C#
202 lines
7.2 KiB
C#
using BlueLaminate.EFCore.Data;
|
|
using BlueLaminate.EFCore.Entities;
|
|
using BlueLaminate.Scraper.Skins;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace BlueLaminate.Cli;
|
|
|
|
/// <param name="Skipped">True when the monthly throttle suppressed the run.</param>
|
|
/// <param name="LastRanAt">When the previous successful run happened, if any.</param>
|
|
public sealed record SkinSyncResult(
|
|
bool Skipped,
|
|
DateTimeOffset? LastRanAt,
|
|
int Loaded,
|
|
int Inserted,
|
|
int Updated,
|
|
int WeaponsCreated,
|
|
int CollectionsCreated);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class SkinSyncService
|
|
{
|
|
public const string Source = "skins";
|
|
|
|
private readonly SkinTrackerDbContext _db;
|
|
private readonly SkinCatalogClient _client;
|
|
private readonly ILogger<SkinSyncService> _logger;
|
|
|
|
public SkinSyncService(
|
|
SkinTrackerDbContext db,
|
|
SkinCatalogClient client,
|
|
ILogger<SkinSyncService> logger)
|
|
{
|
|
_db = db;
|
|
_client = client;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<SkinSyncResult> 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<string, Weapon> 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<Collection> ResolveCollections(
|
|
Dictionary<string, Collection> collections, CatalogSkin s, ref int created)
|
|
{
|
|
var resolved = new List<Collection>(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<Collection> sources)
|
|
{
|
|
skin.Weapon = weapon;
|
|
|
|
var changed = false;
|
|
|
|
void Set<T>(Func<T> get, Action<T> set, T value)
|
|
{
|
|
if (!EqualityComparer<T>.Default.Equals(get(), value))
|
|
{
|
|
set(value);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
Set(() => skin.Name, v => skin.Name = v, s.Name);
|
|
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<decimal?>(() => skin.FloatMin, v => skin.FloatMin = v, s.FloatMin);
|
|
Set<decimal?>(() => 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<Collection> current, List<Collection> 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;
|
|
}
|
|
}
|