Change to static skin catalog population

This commit is contained in:
bob
2026-05-29 18:36:17 -05:00
parent 6f3c0175cd
commit b51f1d9f5f
26 changed files with 3063 additions and 370 deletions

View File

@@ -18,6 +18,7 @@
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.8" />
<PackageReference Include="OpenTelemetry" Version="1.15.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,23 @@
using OpenTelemetry;
using OpenTelemetry.Logs;
namespace BlueLaminate.Cli.Logging;
/// <summary>
/// Minimal console sink for the OpenTelemetry log pipeline: one line per record
/// as "{utc timestamp} {message}". Requires IncludeFormattedMessage so the
/// message arrives with its template arguments already substituted.
/// </summary>
public sealed class CompactConsoleLogExporter : BaseExporter<LogRecord>
{
public override ExportResult Export(in Batch<LogRecord> batch)
{
foreach (var record in batch)
{
var message = record.FormattedMessage ?? record.Body ?? string.Empty;
Console.WriteLine($"{record.Timestamp:yyyy-MM-dd HH:mm:ss.fff'Z'} {message}");
}
return ExportResult.Success;
}
}

View File

@@ -1,8 +1,25 @@
using System.CommandLine;
using BlueLaminate.Cli;
using BlueLaminate.Cli.Logging;
using BlueLaminate.EFCore.Data;
using BlueLaminate.Scraper.Weapons;
using BlueLaminate.Scraper.Wiki;
using BlueLaminate.Scraper.Skins;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
using OpenTelemetry.Resources;
using System.CommandLine;
// OpenTelemetry logging through a compact console sink that prints one
// "{utc timestamp} {message}" line per record. Swapping in an OTLP exporter
// later is a change here. Disposed at process exit so buffered records flush.
using var loggerFactory = LoggerFactory.Create(logging =>
{
logging.AddOpenTelemetry(otel =>
{
otel.SetResourceBuilder(
ResourceBuilder.CreateDefault().AddService("BlueLaminate.Cli"));
otel.IncludeFormattedMessage = true;
otel.AddProcessor(new SimpleLogRecordExportProcessor(new CompactConsoleLogExporter()));
});
});
// Entry point: System.CommandLine builds the command tree, parsing, and help.
// New features are added as additional commands here as they're implemented.
@@ -12,56 +29,73 @@ var forceOption = new Option<bool>("--force")
};
var dryRunOption = new Option<bool>("--dry-run")
{
Description = "Scrape and print the weapons without writing to the database."
Description = "Load and print the skins without writing to the database."
};
var syncWeapons = new Command(
"sync-weapons",
"Scrape the CS2 weapon catalogue from the wiki and upsert it (throttled to once a month).")
var syncSkins = new Command(
"sync-skins",
"Load the CS2 skin catalogue from the CSGO-API dataset and upsert it (throttled to once a month).")
{
forceOption,
dryRunOption,
};
syncWeapons.SetAction((parseResult, ct) =>
SyncWeaponsAsync(parseResult.GetValue(forceOption), parseResult.GetValue(dryRunOption), ct));
syncSkins.SetAction((parseResult, ct) =>
SyncSkinsAsync(
parseResult.GetValue(forceOption),
parseResult.GetValue(dryRunOption),
loggerFactory,
ct));
var root = new RootCommand("BlueLaminate CLI — Counter-Strike skin tracker tools.")
{
syncWeapons,
syncSkins,
};
return await root.Parse(args).InvokeAsync();
// Fetch the CS2 weapon catalogue from the wiki and upsert it. Throttled to once
// a month unless --force is passed; --dry-run scrapes and prints without a DB.
static async Task<int> SyncWeaponsAsync(bool force, bool dryRun, CancellationToken ct)
// Load the CS2 skin catalogue from the CSGO-API dataset and upsert it. Weapons
// and collections are derived from the skins themselves. Throttled to once a
// month unless --force; --dry-run loads and prints without a DB.
static async Task<int> SyncSkinsAsync(
bool force, bool dryRun, ILoggerFactory loggerFactory, CancellationToken ct)
{
var scraper = new WeaponWikiScraper(new WikiPageFetcher(CreateHttpClient()));
var logger = loggerFactory.CreateLogger("BlueLaminate.Cli.SyncSkins");
var client = new SkinCatalogClient(CreateHttpClient());
if (dryRun)
{
var weapons = await scraper.ScrapeAsync(ct);
Console.WriteLine($"Scraped {weapons.Count} weapons (dry run, nothing written):");
foreach (var w in weapons)
Console.WriteLine($" {w.Name,-20} {w.Type,-16} {w.Team}");
logger.LogInformation("Loading skin catalogue (dry run — nothing will be written).");
var skins = await client.FetchAsync(ct);
logger.LogInformation("Loaded {Count} skins.", skins.Count);
Console.WriteLine($"Loaded {skins.Count} skins (dry run, nothing written):");
foreach (var s in skins)
{
var tags = (s.StatTrakAvailable ? " ST" : "") + (s.SouvenirAvailable ? " SV" : "");
var range = s.FloatMin is not null ? $"{s.FloatMin:0.00}-{s.FloatMax:0.00}" : "—";
var sources = s.Sources.Count > 0 ? string.Join(", ", s.Sources.Select(x => x.Name)) : "—";
Console.WriteLine(
$" {s.WeaponName,-16} {s.Name,-24} {s.Rarity,-14} {range,-10} {sources}{tags}");
}
return 0;
}
using var db = new SkinTrackerDbContextFactory().CreateDbContext([]);
var result = await new WeaponSyncService(db, scraper).SyncAsync(force, ct);
var service = new SkinSyncService(db, client, loggerFactory.CreateLogger<SkinSyncService>());
var result = await service.SyncAsync(force, ct);
if (result.Skipped)
{
Console.WriteLine(
$"Skipped: weapons were last synced {result.LastRanAt:u}. "
$"Skipped: skins were last synced {result.LastRanAt:u}. "
+ "Next run allowed one month later — pass --force to override.");
}
else
{
Console.WriteLine(
$"Synced {result.Scraped} weapons: {result.Inserted} inserted, "
$"Synced {result.Loaded} skins: {result.Inserted} inserted, "
+ $"{result.Updated} updated, "
+ $"{result.Scraped - result.Inserted - result.Updated} unchanged.");
+ $"{result.Loaded - result.Inserted - result.Updated} unchanged "
+ $"({result.WeaponsCreated} weapons, {result.CollectionsCreated} collections created).");
}
return 0;
@@ -70,10 +104,7 @@ static async Task<int> SyncWeaponsAsync(bool force, bool dryRun, CancellationTok
static HttpClient CreateHttpClient()
{
var http = new HttpClient();
// The wiki is fronted by Cloudflare; a browser-like User-Agent is accepted
// on the MediaWiki API endpoint the scraper uses.
http.DefaultRequestHeaders.UserAgent.ParseAdd(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36");
http.Timeout = TimeSpan.FromMinutes(2);
http.DefaultRequestHeaders.UserAgent.ParseAdd("BlueLaminate.Cli");
return http;
}

View File

@@ -0,0 +1,201 @@
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;
}
}

View File

@@ -1,77 +0,0 @@
using BlueLaminate.EFCore.Data;
using BlueLaminate.EFCore.Entities;
using BlueLaminate.Scraper.Weapons;
using Microsoft.EntityFrameworkCore;
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 WeaponSyncResult(
bool Skipped,
DateTimeOffset? LastRanAt,
int Scraped,
int Inserted,
int Updated);
/// <summary>
/// Fetches the CS2 weapon catalogue and upserts it into the database. The
/// catalogue changes rarely, so a run is throttled to at most once a month
/// unless explicitly forced.
/// </summary>
public sealed class WeaponSyncService
{
public const string Source = "weapons";
private readonly SkinTrackerDbContext _db;
private readonly WeaponWikiScraper _scraper;
public WeaponSyncService(SkinTrackerDbContext db, WeaponWikiScraper scraper)
{
_db = db;
_scraper = scraper;
}
public async Task<WeaponSyncResult> 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)
return new WeaponSyncResult(Skipped: true, last, Scraped: 0, Inserted: 0, Updated: 0);
var scraped = await _scraper.ScrapeAsync(ct);
var existing = await _db.Weapons.ToDictionaryAsync(w => w.Name, ct);
var inserted = 0;
var updated = 0;
foreach (var s in scraped)
{
if (existing.TryGetValue(s.Name, out var weapon))
{
if (weapon.Type != s.Type || weapon.Team != s.Team)
{
weapon.Type = s.Type;
weapon.Team = s.Team;
updated++;
}
}
else
{
_db.Weapons.Add(new Weapon { Name = s.Name, Type = s.Type, Team = s.Team });
inserted++;
}
}
_db.ScrapeRuns.Add(new ScrapeRun { Source = Source, RanAt = now, ItemCount = scraped.Count });
await _db.SaveChangesAsync(ct);
return new WeaponSyncResult(Skipped: false, lastRanAt, scraped.Count, inserted, updated);
}
}

View File

@@ -0,0 +1,14 @@
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace BlueLaminate.EFCore.Configurations;
public class CollectionConfiguration : IEntityTypeConfiguration<Collection>
{
public void Configure(EntityTypeBuilder<Collection> entity)
{
// Slug is the natural key the sync upserts against.
entity.HasIndex(e => e.Slug).IsUnique();
}
}

View File

@@ -8,20 +8,27 @@ public class SkinConfiguration : IEntityTypeConfiguration<Skin>
{
public void Configure(EntityTypeBuilder<Skin> entity)
{
entity.Property(e => e.FloatMin)
.HasColumnType("numeric(10,9)")
.HasDefaultValue(0.0m);
entity.Property(e => e.FloatMax)
.HasColumnType("numeric(10,9)")
.HasDefaultValue(1.0m);
// Nullable: null means the catalogue gives no wear range (e.g. vanilla
// knives), distinct from a genuine 0.01.0 range.
entity.Property(e => e.FloatMin).HasColumnType("numeric(10,9)");
entity.Property(e => e.FloatMax).HasColumnType("numeric(10,9)");
entity.Property(e => e.TrueFloat)
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", stored: true);
entity.HasIndex(e => e.TrueFloat);
// Slug is the natural key the sync upserts against.
entity.HasIndex(e => e.Slug).IsUnique();
entity.HasOne(e => e.Weapon)
.WithMany(w => w.Skins)
.HasForeignKey(e => e.WeaponId);
// A skin can come from many collections and containers, and each of those
// holds many skins.
entity.HasMany(e => e.Collections)
.WithMany(c => c.Skins)
.UsingEntity(join => join.ToTable("skin_collections"));
}
}

View File

@@ -20,6 +20,7 @@ public class SkinTrackerDbContext : DbContext
public DbSet<Weapon> Weapons => Set<Weapon>();
public DbSet<ScrapeRun> ScrapeRuns => Set<ScrapeRun>();
public DbSet<Collection> Collections => Set<Collection>();
public DbSet<Skin> Skins => Set<Skin>();
public DbSet<SkinCondition> SkinConditions => Set<SkinCondition>();
public DbSet<SteamUser> SteamUsers => Set<SteamUser>();
@@ -38,6 +39,7 @@ public class SkinTrackerDbContext : DbContext
modelBuilder.ApplyConfiguration(new WeaponConfiguration());
modelBuilder.ApplyConfiguration(new ScrapeRunConfiguration());
modelBuilder.ApplyConfiguration(new CollectionConfiguration());
modelBuilder.ApplyConfiguration(new SkinConfiguration());
modelBuilder.ApplyConfiguration(new SkinConditionConfiguration());
modelBuilder.ApplyConfiguration(new SteamUserConfiguration());

View File

@@ -0,0 +1,21 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>
/// A source a skin originates from: either an in-game collection (e.g.
/// "The Dead Hand Collection") or a container/case (e.g. "Glove Case").
/// </summary>
public class Collection
{
public int Id { get; set; }
public string Name { get; set; } = null!;
/// <summary>Stable id from the CSGO-API catalogue, e.g. "collection-set-community-37"
/// or "crate-4288". The natural key.</summary>
public string Slug { get; set; } = null!;
/// <summary>"Collection" or "Container".</summary>
public string Type { get; set; } = null!;
public ICollection<Skin> Skins { get; set; } = new List<Skin>();
}

View File

@@ -6,17 +6,29 @@ public class Skin
public int WeaponId { get; set; }
public Weapon Weapon { get; set; } = null!;
/// <summary>Stable id from the CSGO-API catalogue, e.g. "skin-e757fd7191f9". The natural key.</summary>
public string Slug { get; set; } = null!;
public string Name { get; set; } = null!;
public string Rarity { get; set; } = null!;
public string? Description { get; set; }
public string? ImageUrl { get; set; }
public decimal FloatMin { get; set; }
public decimal FloatMax { get; set; }
public bool StatTrakAvailable { get; set; }
public bool SouvenirAvailable { get; set; }
// Computed in the database: float_min = 0.0 AND float_max = 1.0.
// A skin with a capped float range behaves differently in tradeup calculations.
public bool TrueFloat { get; private set; }
/// <summary>Every collection and container this skin originates from.</summary>
public ICollection<Collection> Collections { get; set; } = new List<Collection>();
// Null when the catalogue gives no wear range (e.g. vanilla knives). Callers
// must treat null as "unknown", not as a full 0.01.0 range.
public decimal? FloatMin { get; set; }
public decimal? FloatMax { get; set; }
// Computed in the database: float_min = 0.0 AND float_max = 1.0; null while the
// bounds are unknown. A skin with a capped float range behaves differently in
// tradeup calculations.
public bool? TrueFloat { get; private set; }
public ICollection<SkinCondition> Conditions { get; set; } = new List<SkinCondition>();
public ICollection<SkinInstance> Instances { get; set; } = new List<SkinInstance>();

View File

@@ -0,0 +1,680 @@
// <auto-generated />
using System;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
[DbContext(typeof(SkinTrackerDbContext))]
[Migration("20260529192841_AddSkinCatalogFields")]
partial class AddSkinCatalogFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skintracker")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_collections");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_collections_slug");
b.ToTable("collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AcquiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acquired_at");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<int>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_inventory_items");
b.HasIndex("AssetId")
.HasDatabaseName("ix_inventory_items_asset_id");
b.HasIndex("SkinInstanceId")
.HasDatabaseName("ix_inventory_items_skin_instance_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_inventory_items_user_id");
b.ToTable("inventory_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_price_histories");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_price_histories_condition_id");
b.HasIndex("SkinId", "ConditionId", "RecordedAt")
.HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
b.ToTable("price_histories", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ItemCount")
.HasColumnType("integer")
.HasColumnName("item_count");
b.Property<DateTimeOffset>("RanAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ran_at");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_scrape_runs");
b.HasIndex("Source", "RanAt")
.HasDatabaseName("ix_scrape_runs_source_ran_at");
b.ToTable("scrape_runs", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CollectionId")
.HasColumnType("integer")
.HasColumnName("collection_id");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<decimal>("FloatMax")
.ValueGeneratedOnAdd()
.HasColumnType("numeric(10,9)")
.HasDefaultValue(1.0m)
.HasColumnName("float_max");
b.Property<decimal>("FloatMin")
.ValueGeneratedOnAdd()
.HasColumnType("numeric(10,9)")
.HasDefaultValue(0.0m)
.HasColumnName("float_min");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Rarity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rarity");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<bool>("SouvenirAvailable")
.HasColumnType("boolean")
.HasColumnName("souvenir_available");
b.Property<bool>("StatTrakAvailable")
.HasColumnType("boolean")
.HasColumnName("stat_trak_available");
b.Property<bool>("TrueFloat")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasColumnName("true_float")
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
b.Property<int>("WeaponId")
.HasColumnType("integer")
.HasColumnName("weapon_id");
b.HasKey("Id")
.HasName("pk_skins");
b.HasIndex("CollectionId")
.HasDatabaseName("ix_skins_collection_id");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_skins_slug");
b.HasIndex("TrueFloat")
.HasDatabaseName("ix_skins_true_float");
b.HasIndex("WeaponId")
.HasDatabaseName("ix_skins_weapon_id");
b.ToTable("skins", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Condition")
.IsRequired()
.HasColumnType("text")
.HasColumnName("condition");
b.Property<decimal>("MaxFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("max_float");
b.Property<decimal>("MinFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("min_float");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.HasKey("Id")
.HasName("pk_skin_conditions");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_conditions_skin_id");
b.ToTable("skin_conditions", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_value");
b.Property<string>("PaintSeed")
.IsRequired()
.HasColumnType("text")
.HasColumnName("paint_seed");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<bool>("Souvenir")
.HasColumnType("boolean")
.HasColumnName("souvenir");
b.Property<bool>("StatTrak")
.HasColumnType("boolean")
.HasColumnName("stat_trak");
b.HasKey("Id")
.HasName("pk_skin_instances");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_skin_instances_condition_id");
b.HasIndex("FloatValue")
.HasDatabaseName("ix_skin_instances_float_value");
b.HasIndex("PaintSeed")
.HasDatabaseName("ix_skin_instances_paint_seed");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_instances_skin_id");
b.ToTable("skin_instances", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<DateTimeOffset>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<string>("SteamId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("steam_id");
b.HasKey("Id")
.HasName("pk_steam_users");
b.HasIndex("SteamId")
.IsUnique()
.HasDatabaseName("ix_steam_users_steam_id");
b.ToTable("steam_users", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromUserId")
.HasColumnType("integer")
.HasColumnName("from_user_id");
b.Property<string>("SteamTradeId")
.HasColumnType("text")
.HasColumnName("steam_trade_id");
b.Property<int>("ToUserId")
.HasColumnType("integer")
.HasColumnName("to_user_id");
b.Property<DateTimeOffset>("TradedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("traded_at");
b.HasKey("Id")
.HasName("pk_trades");
b.HasIndex("FromUserId")
.HasDatabaseName("ix_trades_from_user_id");
b.HasIndex("ToUserId")
.HasDatabaseName("ix_trades_to_user_id");
b.ToTable("trades", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("InventoryItemId")
.HasColumnType("integer")
.HasColumnName("inventory_item_id");
b.Property<int>("TradeId")
.HasColumnType("integer")
.HasColumnName("trade_id");
b.HasKey("Id")
.HasName("pk_trade_items");
b.HasIndex("InventoryItemId")
.HasDatabaseName("ix_trade_items_inventory_item_id");
b.HasIndex("TradeId")
.HasDatabaseName("ix_trade_items_trade_id");
b.ToTable("trade_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Team")
.IsRequired()
.HasColumnType("text")
.HasColumnName("team");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_weapons");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_weapons_name");
b.ToTable("weapons", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("InventoryItems")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
.WithMany("InventoryItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_steam_users_user_id");
b.Navigation("SkinInstance");
b.Navigation("User");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("PriceHistories")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_price_histories_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("PriceHistories")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_price_histories_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Collection", "Collection")
.WithMany("Skins")
.HasForeignKey("CollectionId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_skins_collections_collection_id");
b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
.WithMany("Skins")
.HasForeignKey("WeaponId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skins_weapons_weapon_id");
b.Navigation("Collection");
b.Navigation("Weapon");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Conditions")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_conditions_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("Instances")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Instances")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_instances_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
.WithMany("TradesSent")
.HasForeignKey("FromUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_from_user_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
.WithMany("TradesReceived")
.HasForeignKey("ToUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_to_user_id");
b.Navigation("FromUser");
b.Navigation("ToUser");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
.WithMany("TradeItems")
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
.WithMany("TradeItems")
.HasForeignKey("TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_trades_trade_id");
b.Navigation("InventoryItem");
b.Navigation("Trade");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
{
b.Navigation("Skins");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Navigation("Conditions");
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Navigation("InventoryItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Navigation("InventoryItems");
b.Navigation("TradesReceived");
b.Navigation("TradesSent");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Navigation("Skins");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,135 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class AddSkinCatalogFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "collection_id",
schema: "skintracker",
table: "skins",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "slug",
schema: "skintracker",
table: "skins",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "souvenir_available",
schema: "skintracker",
table: "skins",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "stat_trak_available",
schema: "skintracker",
table: "skins",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "collections",
schema: "skintracker",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
name = table.Column<string>(type: "text", nullable: false),
slug = table.Column<string>(type: "text", nullable: false),
type = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_collections", x => x.id);
});
migrationBuilder.CreateIndex(
name: "ix_skins_collection_id",
schema: "skintracker",
table: "skins",
column: "collection_id");
migrationBuilder.CreateIndex(
name: "ix_skins_slug",
schema: "skintracker",
table: "skins",
column: "slug",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_collections_slug",
schema: "skintracker",
table: "collections",
column: "slug",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_skins_collections_collection_id",
schema: "skintracker",
table: "skins",
column: "collection_id",
principalSchema: "skintracker",
principalTable: "collections",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_skins_collections_collection_id",
schema: "skintracker",
table: "skins");
migrationBuilder.DropTable(
name: "collections",
schema: "skintracker");
migrationBuilder.DropIndex(
name: "ix_skins_collection_id",
schema: "skintracker",
table: "skins");
migrationBuilder.DropIndex(
name: "ix_skins_slug",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "collection_id",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "slug",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "souvenir_available",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "stat_trak_available",
schema: "skintracker",
table: "skins");
}
}
}

View File

@@ -0,0 +1,676 @@
// <auto-generated />
using System;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
[DbContext(typeof(SkinTrackerDbContext))]
[Migration("20260529200100_MakeSkinFloatsNullable")]
partial class MakeSkinFloatsNullable
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skintracker")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_collections");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_collections_slug");
b.ToTable("collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AcquiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acquired_at");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<int>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_inventory_items");
b.HasIndex("AssetId")
.HasDatabaseName("ix_inventory_items_asset_id");
b.HasIndex("SkinInstanceId")
.HasDatabaseName("ix_inventory_items_skin_instance_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_inventory_items_user_id");
b.ToTable("inventory_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_price_histories");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_price_histories_condition_id");
b.HasIndex("SkinId", "ConditionId", "RecordedAt")
.HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
b.ToTable("price_histories", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ItemCount")
.HasColumnType("integer")
.HasColumnName("item_count");
b.Property<DateTimeOffset>("RanAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ran_at");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_scrape_runs");
b.HasIndex("Source", "RanAt")
.HasDatabaseName("ix_scrape_runs_source_ran_at");
b.ToTable("scrape_runs", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CollectionId")
.HasColumnType("integer")
.HasColumnName("collection_id");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<decimal?>("FloatMax")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_max");
b.Property<decimal?>("FloatMin")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_min");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Rarity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rarity");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<bool>("SouvenirAvailable")
.HasColumnType("boolean")
.HasColumnName("souvenir_available");
b.Property<bool>("StatTrakAvailable")
.HasColumnType("boolean")
.HasColumnName("stat_trak_available");
b.Property<bool?>("TrueFloat")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasColumnName("true_float")
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
b.Property<int>("WeaponId")
.HasColumnType("integer")
.HasColumnName("weapon_id");
b.HasKey("Id")
.HasName("pk_skins");
b.HasIndex("CollectionId")
.HasDatabaseName("ix_skins_collection_id");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_skins_slug");
b.HasIndex("TrueFloat")
.HasDatabaseName("ix_skins_true_float");
b.HasIndex("WeaponId")
.HasDatabaseName("ix_skins_weapon_id");
b.ToTable("skins", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Condition")
.IsRequired()
.HasColumnType("text")
.HasColumnName("condition");
b.Property<decimal>("MaxFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("max_float");
b.Property<decimal>("MinFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("min_float");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.HasKey("Id")
.HasName("pk_skin_conditions");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_conditions_skin_id");
b.ToTable("skin_conditions", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_value");
b.Property<string>("PaintSeed")
.IsRequired()
.HasColumnType("text")
.HasColumnName("paint_seed");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<bool>("Souvenir")
.HasColumnType("boolean")
.HasColumnName("souvenir");
b.Property<bool>("StatTrak")
.HasColumnType("boolean")
.HasColumnName("stat_trak");
b.HasKey("Id")
.HasName("pk_skin_instances");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_skin_instances_condition_id");
b.HasIndex("FloatValue")
.HasDatabaseName("ix_skin_instances_float_value");
b.HasIndex("PaintSeed")
.HasDatabaseName("ix_skin_instances_paint_seed");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_instances_skin_id");
b.ToTable("skin_instances", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<DateTimeOffset>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<string>("SteamId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("steam_id");
b.HasKey("Id")
.HasName("pk_steam_users");
b.HasIndex("SteamId")
.IsUnique()
.HasDatabaseName("ix_steam_users_steam_id");
b.ToTable("steam_users", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromUserId")
.HasColumnType("integer")
.HasColumnName("from_user_id");
b.Property<string>("SteamTradeId")
.HasColumnType("text")
.HasColumnName("steam_trade_id");
b.Property<int>("ToUserId")
.HasColumnType("integer")
.HasColumnName("to_user_id");
b.Property<DateTimeOffset>("TradedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("traded_at");
b.HasKey("Id")
.HasName("pk_trades");
b.HasIndex("FromUserId")
.HasDatabaseName("ix_trades_from_user_id");
b.HasIndex("ToUserId")
.HasDatabaseName("ix_trades_to_user_id");
b.ToTable("trades", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("InventoryItemId")
.HasColumnType("integer")
.HasColumnName("inventory_item_id");
b.Property<int>("TradeId")
.HasColumnType("integer")
.HasColumnName("trade_id");
b.HasKey("Id")
.HasName("pk_trade_items");
b.HasIndex("InventoryItemId")
.HasDatabaseName("ix_trade_items_inventory_item_id");
b.HasIndex("TradeId")
.HasDatabaseName("ix_trade_items_trade_id");
b.ToTable("trade_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Team")
.IsRequired()
.HasColumnType("text")
.HasColumnName("team");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_weapons");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_weapons_name");
b.ToTable("weapons", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("InventoryItems")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
.WithMany("InventoryItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_steam_users_user_id");
b.Navigation("SkinInstance");
b.Navigation("User");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("PriceHistories")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_price_histories_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("PriceHistories")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_price_histories_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Collection", "Collection")
.WithMany("Skins")
.HasForeignKey("CollectionId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_skins_collections_collection_id");
b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
.WithMany("Skins")
.HasForeignKey("WeaponId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skins_weapons_weapon_id");
b.Navigation("Collection");
b.Navigation("Weapon");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Conditions")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_conditions_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("Instances")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Instances")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_instances_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
.WithMany("TradesSent")
.HasForeignKey("FromUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_from_user_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
.WithMany("TradesReceived")
.HasForeignKey("ToUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_to_user_id");
b.Navigation("FromUser");
b.Navigation("ToUser");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
.WithMany("TradeItems")
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
.WithMany("TradeItems")
.HasForeignKey("TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_trades_trade_id");
b.Navigation("InventoryItem");
b.Navigation("Trade");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
{
b.Navigation("Skins");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Navigation("Conditions");
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Navigation("InventoryItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Navigation("InventoryItems");
b.Navigation("TradesReceived");
b.Navigation("TradesSent");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Navigation("Skins");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,87 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class MakeSkinFloatsNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<decimal>(
name: "float_min",
schema: "skintracker",
table: "skins",
type: "numeric(10,9)",
nullable: true,
oldClrType: typeof(decimal),
oldType: "numeric(10,9)",
oldDefaultValue: 0.0m);
migrationBuilder.AlterColumn<decimal>(
name: "float_max",
schema: "skintracker",
table: "skins",
type: "numeric(10,9)",
nullable: true,
oldClrType: typeof(decimal),
oldType: "numeric(10,9)",
oldDefaultValue: 1.0m);
migrationBuilder.AlterColumn<bool>(
name: "true_float",
schema: "skintracker",
table: "skins",
type: "boolean",
nullable: true,
computedColumnSql: "float_min = 0.0 AND float_max = 1.0",
stored: true,
oldClrType: typeof(bool),
oldType: "boolean",
oldComputedColumnSql: "float_min = 0.0 AND float_max = 1.0",
oldStored: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<decimal>(
name: "float_min",
schema: "skintracker",
table: "skins",
type: "numeric(10,9)",
nullable: false,
defaultValue: 0.0m,
oldClrType: typeof(decimal),
oldType: "numeric(10,9)",
oldNullable: true);
migrationBuilder.AlterColumn<decimal>(
name: "float_max",
schema: "skintracker",
table: "skins",
type: "numeric(10,9)",
nullable: false,
defaultValue: 1.0m,
oldClrType: typeof(decimal),
oldType: "numeric(10,9)",
oldNullable: true);
migrationBuilder.AlterColumn<bool>(
name: "true_float",
schema: "skintracker",
table: "skins",
type: "boolean",
nullable: false,
computedColumnSql: "float_min = 0.0 AND float_max = 1.0",
stored: true,
oldClrType: typeof(bool),
oldType: "boolean",
oldNullable: true,
oldComputedColumnSql: "float_min = 0.0 AND float_max = 1.0",
oldStored: true);
}
}
}

View File

@@ -0,0 +1,692 @@
// <auto-generated />
using System;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
[DbContext(typeof(SkinTrackerDbContext))]
[Migration("20260529211544_UseStaticSkinCatalog")]
partial class UseStaticSkinCatalog
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skintracker")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_collections");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_collections_slug");
b.ToTable("collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AcquiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acquired_at");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<int>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_inventory_items");
b.HasIndex("AssetId")
.HasDatabaseName("ix_inventory_items_asset_id");
b.HasIndex("SkinInstanceId")
.HasDatabaseName("ix_inventory_items_skin_instance_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_inventory_items_user_id");
b.ToTable("inventory_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_price_histories");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_price_histories_condition_id");
b.HasIndex("SkinId", "ConditionId", "RecordedAt")
.HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
b.ToTable("price_histories", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ItemCount")
.HasColumnType("integer")
.HasColumnName("item_count");
b.Property<DateTimeOffset>("RanAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ran_at");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_scrape_runs");
b.HasIndex("Source", "RanAt")
.HasDatabaseName("ix_scrape_runs_source_ran_at");
b.ToTable("scrape_runs", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<decimal?>("FloatMax")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_max");
b.Property<decimal?>("FloatMin")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_min");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Rarity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rarity");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<bool>("SouvenirAvailable")
.HasColumnType("boolean")
.HasColumnName("souvenir_available");
b.Property<bool>("StatTrakAvailable")
.HasColumnType("boolean")
.HasColumnName("stat_trak_available");
b.Property<bool?>("TrueFloat")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasColumnName("true_float")
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
b.Property<int>("WeaponId")
.HasColumnType("integer")
.HasColumnName("weapon_id");
b.HasKey("Id")
.HasName("pk_skins");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_skins_slug");
b.HasIndex("TrueFloat")
.HasDatabaseName("ix_skins_true_float");
b.HasIndex("WeaponId")
.HasDatabaseName("ix_skins_weapon_id");
b.ToTable("skins", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Condition")
.IsRequired()
.HasColumnType("text")
.HasColumnName("condition");
b.Property<decimal>("MaxFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("max_float");
b.Property<decimal>("MinFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("min_float");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.HasKey("Id")
.HasName("pk_skin_conditions");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_conditions_skin_id");
b.ToTable("skin_conditions", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_value");
b.Property<string>("PaintSeed")
.IsRequired()
.HasColumnType("text")
.HasColumnName("paint_seed");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<bool>("Souvenir")
.HasColumnType("boolean")
.HasColumnName("souvenir");
b.Property<bool>("StatTrak")
.HasColumnType("boolean")
.HasColumnName("stat_trak");
b.HasKey("Id")
.HasName("pk_skin_instances");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_skin_instances_condition_id");
b.HasIndex("FloatValue")
.HasDatabaseName("ix_skin_instances_float_value");
b.HasIndex("PaintSeed")
.HasDatabaseName("ix_skin_instances_paint_seed");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_instances_skin_id");
b.ToTable("skin_instances", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<DateTimeOffset>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<string>("SteamId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("steam_id");
b.HasKey("Id")
.HasName("pk_steam_users");
b.HasIndex("SteamId")
.IsUnique()
.HasDatabaseName("ix_steam_users_steam_id");
b.ToTable("steam_users", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromUserId")
.HasColumnType("integer")
.HasColumnName("from_user_id");
b.Property<string>("SteamTradeId")
.HasColumnType("text")
.HasColumnName("steam_trade_id");
b.Property<int>("ToUserId")
.HasColumnType("integer")
.HasColumnName("to_user_id");
b.Property<DateTimeOffset>("TradedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("traded_at");
b.HasKey("Id")
.HasName("pk_trades");
b.HasIndex("FromUserId")
.HasDatabaseName("ix_trades_from_user_id");
b.HasIndex("ToUserId")
.HasDatabaseName("ix_trades_to_user_id");
b.ToTable("trades", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("InventoryItemId")
.HasColumnType("integer")
.HasColumnName("inventory_item_id");
b.Property<int>("TradeId")
.HasColumnType("integer")
.HasColumnName("trade_id");
b.HasKey("Id")
.HasName("pk_trade_items");
b.HasIndex("InventoryItemId")
.HasDatabaseName("ix_trade_items_inventory_item_id");
b.HasIndex("TradeId")
.HasDatabaseName("ix_trade_items_trade_id");
b.ToTable("trade_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Team")
.IsRequired()
.HasColumnType("text")
.HasColumnName("team");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_weapons");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_weapons_name");
b.ToTable("weapons", "skintracker");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.Property<int>("CollectionsId")
.HasColumnType("integer")
.HasColumnName("collections_id");
b.Property<int>("SkinsId")
.HasColumnType("integer")
.HasColumnName("skins_id");
b.HasKey("CollectionsId", "SkinsId")
.HasName("pk_skin_collections");
b.HasIndex("SkinsId")
.HasDatabaseName("ix_skin_collections_skins_id");
b.ToTable("skin_collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("InventoryItems")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
.WithMany("InventoryItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_steam_users_user_id");
b.Navigation("SkinInstance");
b.Navigation("User");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("PriceHistories")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_price_histories_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("PriceHistories")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_price_histories_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
.WithMany("Skins")
.HasForeignKey("WeaponId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skins_weapons_weapon_id");
b.Navigation("Weapon");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Conditions")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_conditions_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("Instances")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Instances")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_instances_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
.WithMany("TradesSent")
.HasForeignKey("FromUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_from_user_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
.WithMany("TradesReceived")
.HasForeignKey("ToUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_to_user_id");
b.Navigation("FromUser");
b.Navigation("ToUser");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
.WithMany("TradeItems")
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
.WithMany("TradeItems")
.HasForeignKey("TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_trades_trade_id");
b.Navigation("InventoryItem");
b.Navigation("Trade");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Collection", null)
.WithMany()
.HasForeignKey("CollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_collections_collections_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", null)
.WithMany()
.HasForeignKey("SkinsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_skins_skins_id");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Navigation("Conditions");
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Navigation("InventoryItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Navigation("InventoryItems");
b.Navigation("TradesReceived");
b.Navigation("TradesSent");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Navigation("Skins");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,93 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class UseStaticSkinCatalog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_skins_collections_collection_id",
schema: "skintracker",
table: "skins");
migrationBuilder.DropIndex(
name: "ix_skins_collection_id",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "collection_id",
schema: "skintracker",
table: "skins");
migrationBuilder.CreateTable(
name: "skin_collections",
schema: "skintracker",
columns: table => new
{
collections_id = table.Column<int>(type: "integer", nullable: false),
skins_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_skin_collections", x => new { x.collections_id, x.skins_id });
table.ForeignKey(
name: "fk_skin_collections_collections_collections_id",
column: x => x.collections_id,
principalSchema: "skintracker",
principalTable: "collections",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_skin_collections_skins_skins_id",
column: x => x.skins_id,
principalSchema: "skintracker",
principalTable: "skins",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_skin_collections_skins_id",
schema: "skintracker",
table: "skin_collections",
column: "skins_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "skin_collections",
schema: "skintracker");
migrationBuilder.AddColumn<int>(
name: "collection_id",
schema: "skintracker",
table: "skins",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_skins_collection_id",
schema: "skintracker",
table: "skins",
column: "collection_id");
migrationBuilder.AddForeignKey(
name: "fk_skins_collections_collection_id",
schema: "skintracker",
table: "skins",
column: "collection_id",
principalSchema: "skintracker",
principalTable: "collections",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
}
}

View File

@@ -23,6 +23,40 @@ namespace BlueLaminate.EFCore.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_collections");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_collections_slug");
b.ToTable("collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Property<int>("Id")
@@ -156,16 +190,12 @@ namespace BlueLaminate.EFCore.Migrations
.HasColumnType("text")
.HasColumnName("description");
b.Property<decimal>("FloatMax")
.ValueGeneratedOnAdd()
b.Property<decimal?>("FloatMax")
.HasColumnType("numeric(10,9)")
.HasDefaultValue(1.0m)
.HasColumnName("float_max");
b.Property<decimal>("FloatMin")
.ValueGeneratedOnAdd()
b.Property<decimal?>("FloatMin")
.HasColumnType("numeric(10,9)")
.HasDefaultValue(0.0m)
.HasColumnName("float_min");
b.Property<string>("ImageUrl")
@@ -182,7 +212,20 @@ namespace BlueLaminate.EFCore.Migrations
.HasColumnType("text")
.HasColumnName("rarity");
b.Property<bool>("TrueFloat")
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<bool>("SouvenirAvailable")
.HasColumnType("boolean")
.HasColumnName("souvenir_available");
b.Property<bool>("StatTrakAvailable")
.HasColumnType("boolean")
.HasColumnName("stat_trak_available");
b.Property<bool?>("TrueFloat")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasColumnName("true_float")
@@ -195,6 +238,10 @@ namespace BlueLaminate.EFCore.Migrations
b.HasKey("Id")
.HasName("pk_skins");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_skins_slug");
b.HasIndex("TrueFloat")
.HasDatabaseName("ix_skins_true_float");
@@ -427,6 +474,25 @@ namespace BlueLaminate.EFCore.Migrations
b.ToTable("weapons", "skintracker");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.Property<int>("CollectionsId")
.HasColumnType("integer")
.HasColumnName("collections_id");
b.Property<int>("SkinsId")
.HasColumnType("integer")
.HasColumnName("skins_id");
b.HasKey("CollectionsId", "SkinsId")
.HasName("pk_skin_collections");
b.HasIndex("SkinsId")
.HasDatabaseName("ix_skin_collections_skins_id");
b.ToTable("skin_collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
@@ -556,6 +622,23 @@ namespace BlueLaminate.EFCore.Migrations
b.Navigation("Trade");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Collection", null)
.WithMany()
.HasForeignKey("CollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_collections_collections_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", null)
.WithMany()
.HasForeignKey("SkinsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_skins_skins_id");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Navigation("TradeItems");

View File

@@ -6,8 +6,4 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,36 @@
namespace BlueLaminate.Scraper.Skins;
/// <summary>A single CS2 skin from the CSGO-API static catalogue (skins.json).</summary>
/// <param name="Id">Stable catalogue id, e.g. "skin-e757fd7191f9". Globally unique natural key.</param>
/// <param name="WeaponName">Owning weapon, e.g. "AK-47", "Hand Wraps", "Bayonet".</param>
/// <param name="Category">Weapon category, e.g. "Rifles", "Knives", "Gloves". Becomes the weapon type.</param>
/// <param name="Team">"CT", "T", or "Both".</param>
/// <param name="Name">Skin/pattern name, e.g. "Dragon Lore"; "Vanilla" for knives with no finish.</param>
/// <param name="Rarity">Rarity tier, e.g. "Covert", "Classified", "Extraordinary".</param>
/// <param name="Description">Flavour/description text, or null.</param>
/// <param name="ImageUrl">Catalogue image URL, or null.</param>
/// <param name="StatTrakAvailable">True if a StatTrak variant exists.</param>
/// <param name="SouvenirAvailable">True if a Souvenir variant exists.</param>
/// <param name="FloatMin">Minimum wear value, or null when the catalogue gives none (e.g. vanilla knives).</param>
/// <param name="FloatMax">Maximum wear value, or null.</param>
/// <param name="Sources">Collections and containers this skin belongs to.</param>
public sealed record CatalogSkin(
string Id,
string WeaponName,
string Category,
string Team,
string Name,
string Rarity,
string? Description,
string? ImageUrl,
bool StatTrakAvailable,
bool SouvenirAvailable,
decimal? FloatMin,
decimal? FloatMax,
IReadOnlyList<CatalogSource> Sources);
/// <summary>A collection or container a skin originates from.</summary>
/// <param name="Id">Stable catalogue id, e.g. "collection-set-community-37" or "crate-4288". Natural key.</param>
/// <param name="Name">Display name, e.g. "The Dead Hand Collection", "Glove Case".</param>
/// <param name="Type">"Collection" or "Container".</param>
public sealed record CatalogSource(string Id, string Name, string Type);

View File

@@ -0,0 +1,105 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace BlueLaminate.Scraper.Skins;
/// <summary>
/// Loads the CS2 skin catalogue from the ByMykel/CSGO-API static dataset
/// (skins.json) and maps it to <see cref="CatalogSkin"/> records. This replaces
/// the old HTML scraper: one JSON file carries every skin with its weapon,
/// category, rarity, wear range, and the collections/containers it comes from.
/// </summary>
public sealed class SkinCatalogClient
{
public const string DefaultUrl =
"https://raw.githubusercontent.com/ByMykel/CSGO-API/refs/heads/main/public/api/en/skins.json";
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
};
private readonly HttpClient _http;
private readonly string _url;
public SkinCatalogClient(HttpClient http, string? url = null)
{
_http = http;
_url = url ?? DefaultUrl;
}
public async Task<IReadOnlyList<CatalogSkin>> FetchAsync(CancellationToken ct = default)
{
await using var stream = await _http.GetStreamAsync(_url, ct);
var dtos = await JsonSerializer.DeserializeAsync<List<SkinDto>>(stream, Options, ct)
?? throw new InvalidOperationException("skins.json deserialized to null.");
return dtos.Select(Map).ToList();
}
private static CatalogSkin Map(SkinDto dto)
{
var sources = new List<CatalogSource>();
AddSources(sources, dto.Collections, "Collection");
AddSources(sources, dto.Crates, "Container");
return new CatalogSkin(
Id: dto.Id,
WeaponName: dto.Weapon?.Name ?? "Unknown",
Category: dto.Category?.Name ?? "Unknown",
Team: MapTeam(dto.Team?.Id),
// Knives with no finish carry a null pattern; "Vanilla" is the community term.
Name: dto.Pattern?.Name ?? "Vanilla",
Rarity: dto.Rarity?.Name ?? "Unknown",
Description: dto.Description,
ImageUrl: dto.Image,
StatTrakAvailable: dto.Stattrak,
SouvenirAvailable: dto.Souvenir,
FloatMin: dto.MinFloat,
FloatMax: dto.MaxFloat,
Sources: sources);
}
private static void AddSources(List<CatalogSource> into, List<NamedDto>? items, string type)
{
if (items is null)
return;
foreach (var item in items)
{
if (string.IsNullOrEmpty(item.Id) || string.IsNullOrEmpty(item.Name))
continue;
if (into.Any(s => s.Id == item.Id))
continue;
into.Add(new CatalogSource(item.Id, item.Name, type));
}
}
private static string MapTeam(string? teamId) => teamId switch
{
"terrorists" => "T",
"counter-terrorists" => "CT",
_ => "Both",
};
private sealed record SkinDto(
string Id,
string? Name,
string? Description,
NamedDto? Weapon,
NamedDto? Category,
NamedDto? Pattern,
decimal? MinFloat,
decimal? MaxFloat,
NamedDto? Rarity,
bool Stattrak,
bool Souvenir,
string? Image,
NamedDto? Team,
List<NamedDto>? Collections,
List<NamedDto>? Crates);
private sealed record NamedDto(string? Id, string? Name);
}

View File

@@ -1,7 +0,0 @@
namespace BlueLaminate.Scraper.Weapons;
/// <summary>A single CS2 weapon parsed from the Counter-Strike wiki.</summary>
/// <param name="Name">Display name, e.g. "AK-47".</param>
/// <param name="Type">Category from the wiki heading, e.g. "Pistols", "Assault Rifles".</param>
/// <param name="Team">"CT", "T", or "Both".</param>
public sealed record ScrapedWeapon(string Name, string Type, string Team);

View File

@@ -1,172 +0,0 @@
using System.Text.RegularExpressions;
using BlueLaminate.Scraper.Wiki;
using HtmlAgilityPack;
namespace BlueLaminate.Scraper.Weapons;
/// <summary>
/// Scrapes the CS2 weapon catalogue from the wiki's "Weapons" page.
///
/// Layout: the page has one "tabber" per weapon category, each immediately
/// preceded by a section heading (the category / Type). Inside each tabber the
/// "Global Offensive &amp; Counter-Strike 2" tab holds a gallery of captions —
/// one per weapon, optionally suffixed with "(CT)" or "(T)" for team-locked
/// weapons.
/// </summary>
public sealed class WeaponWikiScraper
{
private const string Page = "Weapons";
private const string Cs2TabHash = "Global_Offensive_&_Counter-Strike_2";
// Matches a trailing "(CT)" / "(T)" team annotation, capturing the team.
private static readonly Regex TeamAnnotation =
new(@"\s*\((CT|T)\)\s*$", RegexOptions.Compiled);
// The wiki labels the default knife "Stock Knife"; drop the prefix.
private static readonly Regex StockPrefix =
new(@"^Stock\s+", RegexOptions.Compiled);
private readonly WikiPageFetcher _fetcher;
public WeaponWikiScraper(WikiPageFetcher fetcher) => _fetcher = fetcher;
public async Task<IReadOnlyList<ScrapedWeapon>> ScrapeAsync(CancellationToken ct = default)
{
var doc = await _fetcher.LoadAsync(Page, ct);
// Headings and tabbers in document order so each tabber inherits the
// most recent heading as its category.
var nodes = doc.DocumentNode.SelectNodes(
"//h2 | //h3 | //h4 | "
+ "//div[contains(concat(' ', normalize-space(@class), ' '), ' tabber ')]");
var aggregator = new WeaponAggregator();
string? currentType = null;
if (nodes is not null)
{
foreach (var node in nodes)
{
if (node.Name is "h2" or "h3" or "h4")
{
currentType = HeadingText(node);
continue;
}
if (currentType is null)
continue;
foreach (var caption in Cs2Captions(node))
aggregator.Add(caption, currentType);
}
}
return aggregator.Build();
}
/// <summary>Caption texts from the CS2 tab of a single tabber, if present.</summary>
private static IEnumerable<string> Cs2Captions(HtmlNode tabber)
{
var tabs = tabber.SelectNodes(
".//li[contains(concat(' ', normalize-space(@class), ' '), ' wds-tabs__tab ')]");
if (tabs is null)
yield break;
var index = -1;
for (var i = 0; i < tabs.Count; i++)
{
// HtmlAgilityPack returns attribute values un-decoded, and the wiki
// entity-encodes the "&" in this hash (&amp;).
var hash = HtmlEntity.DeEntitize(tabs[i].GetAttributeValue("data-hash", string.Empty));
if (hash == Cs2TabHash)
{
index = i;
break;
}
}
if (index < 0)
yield break;
var contents = tabber.SelectNodes(
".//div[contains(concat(' ', normalize-space(@class), ' '), ' wds-tab__content ')]");
if (contents is null || index >= contents.Count)
yield break;
var captions = contents[index].SelectNodes(
".//div[contains(concat(' ', normalize-space(@class), ' '), ' lightbox-caption ')]");
if (captions is null)
yield break;
foreach (var caption in captions)
yield return WikiText.Normalize(caption.InnerText);
}
private static string HeadingText(HtmlNode heading)
{
var headline = heading.SelectSingleNode(
".//span[contains(concat(' ', normalize-space(@class), ' '), ' mw-headline ')]");
return WikiText.Normalize((headline ?? heading).InnerText);
}
/// <summary>
/// Collapses the per-caption rows into one weapon per name, tracking which
/// teams it appeared for so a weapon shown as both "(CT)" and "(T)" (or with
/// no annotation) resolves to "Both".
/// </summary>
private sealed class WeaponAggregator
{
private sealed class Entry
{
public required string Type { get; init; }
public bool SawCt;
public bool SawT;
public bool SawUnannotated;
}
private readonly Dictionary<string, Entry> _byName = new();
private readonly List<string> _order = new();
public void Add(string caption, string type)
{
if (string.IsNullOrEmpty(caption))
return;
var match = TeamAnnotation.Match(caption);
var name = TeamAnnotation.Replace(caption, string.Empty);
name = StockPrefix.Replace(name, string.Empty).Trim();
if (name.Length == 0)
return;
if (!_byName.TryGetValue(name, out var entry))
{
entry = new Entry { Type = type };
_byName[name] = entry;
_order.Add(name);
}
if (!match.Success)
entry.SawUnannotated = true;
else if (match.Groups[1].Value == "CT")
entry.SawCt = true;
else
entry.SawT = true;
}
public IReadOnlyList<ScrapedWeapon> Build()
{
var result = new List<ScrapedWeapon>(_order.Count);
foreach (var name in _order)
{
var e = _byName[name];
var team =
e.SawUnannotated || (e.SawCt && e.SawT) ? "Both"
: e.SawCt ? "CT"
: e.SawT ? "T"
: "Both";
result.Add(new ScrapedWeapon(name, e.Type, team));
}
return result;
}
}
}

View File

@@ -1,51 +0,0 @@
using System.Text.Json;
using HtmlAgilityPack;
namespace BlueLaminate.Scraper.Wiki;
/// <summary>
/// Fetches a rendered page from the Counter-Strike Fandom wiki, shared by all
/// wiki scrapers.
///
/// The rendered HTML pages sit behind Cloudflare, which 403s .NET's TLS
/// fingerprint regardless of headers. The MediaWiki <c>action=parse</c> API is
/// not challenged, so we fetch the same content as JSON from there and return
/// the embedded HTML as a parsed document.
/// </summary>
public sealed class WikiPageFetcher
{
private const string ApiBase = "https://counterstrike.fandom.com/api.php";
private readonly HttpClient _http;
public WikiPageFetcher(HttpClient http) => _http = http;
/// <summary>Loads a wiki page (e.g. "Weapons") as a parsed HTML document.</summary>
public async Task<HtmlDocument> LoadAsync(string page, CancellationToken ct = default)
{
var url = $"{ApiBase}?action=parse&page={Uri.EscapeDataString(page)}&prop=text&format=json";
using var resp = await _http.GetAsync(url, ct);
resp.EnsureSuccessStatusCode();
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var json = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
if (json.RootElement.TryGetProperty("error", out var error))
{
var info = error.TryGetProperty("info", out var i) ? i.GetString() : "unknown error";
throw new InvalidOperationException($"Wiki API returned an error for page '{page}': {info}");
}
var html = json.RootElement
.GetProperty("parse")
.GetProperty("text")
.GetProperty("*")
.GetString()
?? throw new InvalidOperationException($"Wiki API response for page '{page}' had no parsed text.");
var doc = new HtmlDocument();
doc.LoadHtml(html);
return doc;
}
}

View File

@@ -1,14 +0,0 @@
using System.Text.RegularExpressions;
using HtmlAgilityPack;
namespace BlueLaminate.Scraper.Wiki;
/// <summary>Text helpers shared by wiki scrapers.</summary>
public static class WikiText
{
private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled);
/// <summary>Decodes HTML entities and collapses whitespace runs to single spaces.</summary>
public static string Normalize(string raw) =>
Whitespace.Replace(HtmlEntity.DeEntitize(raw) ?? string.Empty, " ").Trim();
}

37
db/02_readonly_role.sql Normal file
View File

@@ -0,0 +1,37 @@
-- ============================================================
-- CS2 Skin Tracker — read-only reporting/BI role
-- Run as a superuser (e.g. postgres) OR as skintracker_app,
-- connected to the skintracker database. Safe to re-run.
--
-- Replace the password placeholder before running.
-- This role can SELECT every table in the skintracker schema
-- (existing and future) and nothing else: no INSERT/UPDATE/
-- DELETE, no DDL, no access to other schemas.
-- ============================================================
-- 1. Login role, guarded so the script is idempotent.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'skintracker_readonly') THEN
CREATE ROLE skintracker_readonly WITH LOGIN PASSWORD 'change-me-readonly-password';
END IF;
END
$$;
-- 2. Allow the role to connect to the database and resolve names
-- in the skintracker schema by default.
GRANT CONNECT ON DATABASE skintracker TO skintracker_readonly;
GRANT USAGE ON SCHEMA skintracker TO skintracker_readonly;
ALTER ROLE skintracker_readonly SET search_path = skintracker;
-- 3. Read access to every existing table/view in the schema.
GRANT SELECT ON ALL TABLES IN SCHEMA skintracker TO skintracker_readonly;
-- 4. Read access to tables the app role creates LATER (e.g. new
-- migrations). Without this, future tables would not be visible.
-- Must name the role that OWNS new objects — that is skintracker_app.
ALTER DEFAULT PRIVILEGES FOR ROLE skintracker_app IN SCHEMA skintracker
GRANT SELECT ON TABLES TO skintracker_readonly;
-- 5. Belt-and-braces: ensure the role can never write to public either.
REVOKE CREATE ON SCHEMA public FROM skintracker_readonly;

82
db/03_catalog_audit.sql Normal file
View File

@@ -0,0 +1,82 @@
-- ============================================================
-- CS2 Skin Tracker — catalog audit (read-only)
-- Run against the skintracker database as any role with SELECT
-- (e.g. skintracker_readonly). Pure SELECTs, safe to re-run.
--
-- Purpose: sanity-check the weapon/glove skin catalog for the
-- gaps that break tradeup math and pricing joins.
-- ============================================================
SET search_path = skintracker;
-- 1. Totals — quick scale check.
SELECT 'skins' AS table, count(*) FROM skins
UNION ALL SELECT 'weapons', count(*) FROM weapons
UNION ALL SELECT 'collections', count(*) FROM collections
UNION ALL SELECT 'skin_collections', count(*) FROM skin_collections
UNION ALL SELECT 'skin_conditions', count(*) FROM skin_conditions;
-- 2. Orphan skins: belong to NO collection/container.
-- Expected: only "Howl" (Contraband, removed from its collection).
SELECT s.id, s.slug, s.name, s.rarity
FROM skins s
LEFT JOIN skin_collections sc ON sc.skins_id = s.id
WHERE sc.skins_id IS NULL
ORDER BY s.name;
-- 3. Missing float bounds.
-- Expected: only Vanilla (default) knives, which have no wear range.
SELECT s.id, s.slug, s.name, s.float_min, s.float_max
FROM skins s
WHERE s.float_min IS NULL OR s.float_max IS NULL
ORDER BY s.name;
-- 4. Rarity hygiene: any nulls/blanks, and the distribution.
SELECT coalesce(nullif(rarity, ''), '<empty/null>') AS rarity, count(*)
FROM skins
GROUP BY 1
ORDER BY 2 DESC;
-- 5. Inverted or out-of-range float bounds (should return 0 rows).
SELECT id, slug, name, float_min, float_max
FROM skins
WHERE float_min > float_max OR float_min < 0 OR float_max > 1;
-- 6. Duplicate slugs (unique index should keep this empty).
SELECT slug, count(*)
FROM skins
GROUP BY slug
HAVING count(*) > 1;
-- 7. Collection type split (expect only Collection / Container).
SELECT coalesce(type, '<null>') AS type, count(*)
FROM collections
GROUP BY 1
ORDER BY 2 DESC;
-- 8. Skins with no weapon link (should return 0 rows).
SELECT s.id, s.slug
FROM skins s
LEFT JOIN weapons w ON w.id = s.weapon_id
WHERE w.id IS NULL;
-- 9. Skin count per weapon type — confirms only weapon/glove items.
-- Note: Zeus x27 (type "Equipment") skins ARE valid catalog/tradeup items.
SELECT w.type, count(s.id) AS skins
FROM weapons w
LEFT JOIN skins s ON s.weapon_id = w.id
GROUP BY w.type
ORDER BY skins DESC;
-- 10. StatTrak / Souvenir availability.
SELECT
sum(CASE WHEN stat_trak_available THEN 1 ELSE 0 END) AS stattrak,
sum(CASE WHEN souvenir_available THEN 1 ELSE 0 END) AS souvenir,
count(*) AS total
FROM skins;
-- 11. true_float split (capped range vs full 0.01.0; null = unknown bounds).
SELECT coalesce(true_float::text, '<null>') AS true_float, count(*)
FROM skins
GROUP BY 1
ORDER BY 2 DESC;