diff --git a/README.md b/README.md index 401315b..699d754 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,34 @@ that copy's condition — so changing a condition re-prices it with no further lookup. Every value carries the source that wrote it and the moment it was captured. +**Using PriceCharting.** Subscribe, take the token from the Subscriptions page +("API/Download"), and set `PRICECHARTING_TOKEN` in `.env`. Their API access +comes with a paid subscription; the bulk CSV download is limited to their top +tier, so check which tier you need before subscribing — this integration does +per-game lookups and only needs the API. + +Run a dry run first: + +```bash +curl -X POST localhost:8080/api/prices/refresh \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"provider":"pricecharting","dryRun":true,"limit":10}' +``` + +The response reports **which product each game matched** — name, console and id +— alongside the prices. That matters more than it sounds: a lookup for the DS +"Chrono Trigger" that quietly resolves to the SNES original returns entirely +plausible numbers for the wrong game. Check the matches, then run without +`dryRun`. + +Once a game is priced, the matched product id is stored and later refreshes look +it up directly, so they are cheaper and cannot drift to a different edition. + +Their published API docs are not reachable without an account, so the response +parser follows the widely-used convention — integer pennies under hyphenated +keys — and is tolerant enough that a naming mismatch reads as "no price" rather +than throwing. `PriceChartingProvider.Parse` is the one place to adjust. + ### Database changes ```bash diff --git a/backend/src/LudosData.Api/Controllers/PricesController.cs b/backend/src/LudosData.Api/Controllers/PricesController.cs index d665e12..1f8cc1a 100644 --- a/backend/src/LudosData.Api/Controllers/PricesController.cs +++ b/backend/src/LudosData.Api/Controllers/PricesController.cs @@ -30,7 +30,11 @@ public record PriceRefreshRequest public record PriceRefreshItem( int GameId, string Title, decimal? Loose, decimal? Cib, decimal? New, - int Samples, int Discarded, string? Error); + int Samples, int Discarded, string? Error, + // What the source says it priced. A DS entry that quietly resolves to the + // SNES original returns plausible numbers for the wrong game, so a dry run + // has to show this before anything is written. + string? MatchedName = null, string? MatchedConsole = null, string? SourceId = null); public record PriceRefreshResult( bool DryRun, string Source, int Considered, int Priced, int Failed, @@ -122,26 +126,32 @@ public class PricesController( { try { - var estimate = await provider.EstimateAsync(game.Title, game.System, ct); + // Reuse a previous match where there is one, so refreshes stay + // pinned to the same product instead of re-running a search. + var estimate = await provider.EstimateAsync( + game.Title, game.System, + provider.Name == game.MarketValueSource ? game.PriceSourceId : null, ct); if (!estimate.HasAnyPrice) { failed++; items.Add(new PriceRefreshItem(game.Id, game.Title, null, null, null, - 0, estimate.Discarded, "No usable listings found")); + 0, estimate.Discarded, "No price found for this title")); continue; } if (!request.DryRun) { ApplyEstimate(game, estimate.Loose, estimate.Cib, estimate.New, provider.Name); + game.PriceSourceId = estimate.SourceId ?? game.PriceSourceId; } priced++; items.Add(new PriceRefreshItem(game.Id, game.Title, estimate.Loose, estimate.Cib, estimate.New, estimate.LooseSamples + estimate.CibSamples + estimate.NewSamples, - estimate.Discarded, null)); + estimate.Discarded, null, + estimate.MatchedName, estimate.MatchedConsole, estimate.SourceId)); } catch (Exception ex) when (ex is not OperationCanceledException) { diff --git a/backend/src/LudosData.Api/Data/Migrations/20260804205124_AddPriceSourceId.Designer.cs b/backend/src/LudosData.Api/Data/Migrations/20260804205124_AddPriceSourceId.Designer.cs new file mode 100644 index 0000000..7068270 --- /dev/null +++ b/backend/src/LudosData.Api/Data/Migrations/20260804205124_AddPriceSourceId.Designer.cs @@ -0,0 +1,410 @@ +// +using System; +using LudosData.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LudosData.Api.Data.Migrations +{ + [DbContext(typeof(LudosDbContext))] + [Migration("20260804205124_AddPriceSourceId")] + partial class AddPriceSourceId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("LudosData.Api.Domain.AppUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("Art") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("LudosData.Api.Domain.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Art") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Condition") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Developer") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Dumped") + .HasColumnType("INTEGER"); + + b.Property("Finished") + .HasColumnType("INTEGER"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MarketValue") + .HasColumnType("INTEGER"); + + b.Property("MarketValueSource") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MarketValueUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Own") + .HasColumnType("INTEGER"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("PriceSourceId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Publisher") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PurchaseDate") + .HasColumnType("TEXT"); + + b.Property("PurchasePrice") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasColumnType("INTEGER"); + + b.Property("System") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueCib") + .HasColumnType("INTEGER"); + + b.Property("ValueLoose") + .HasColumnType("INTEGER"); + + b.Property("ValueNew") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId", "Genre"); + + b.HasIndex("OwnerId", "Rating"); + + b.HasIndex("OwnerId", "System"); + + b.HasIndex("OwnerId", "Title"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("LudosData.Api.Domain.Game", b => + { + b.HasOne("LudosData.Api.Domain.AppUser", "Owner") + .WithMany("Games") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("LudosData.Api.Domain.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("LudosData.Api.Domain.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LudosData.Api.Domain.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("LudosData.Api.Domain.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LudosData.Api.Domain.AppUser", b => + { + b.Navigation("Games"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/LudosData.Api/Data/Migrations/20260804205124_AddPriceSourceId.cs b/backend/src/LudosData.Api/Data/Migrations/20260804205124_AddPriceSourceId.cs new file mode 100644 index 0000000..c5ec15c --- /dev/null +++ b/backend/src/LudosData.Api/Data/Migrations/20260804205124_AddPriceSourceId.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LudosData.Api.Data.Migrations +{ + /// + public partial class AddPriceSourceId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PriceSourceId", + table: "Games", + type: "TEXT", + maxLength: 100, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PriceSourceId", + table: "Games"); + } + } +} diff --git a/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs b/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs index d0c9e7d..65397be 100644 --- a/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs +++ b/backend/src/LudosData.Api/Data/Migrations/LudosDbContextModelSnapshot.cs @@ -149,6 +149,10 @@ namespace LudosData.Api.Data.Migrations b.Property("Played") .HasColumnType("INTEGER"); + b.Property("PriceSourceId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + b.Property("Publisher") .HasMaxLength(100) .HasColumnType("TEXT"); diff --git a/backend/src/LudosData.Api/Domain/Game.cs b/backend/src/LudosData.Api/Domain/Game.cs index 4a510e5..84942e9 100644 --- a/backend/src/LudosData.Api/Domain/Game.cs +++ b/backend/src/LudosData.Api/Domain/Game.cs @@ -94,6 +94,15 @@ public class Game // re-prices it without another lookup, and the dashboard can answer both // "what is this worth" and "what would it be worth complete". + /// + /// The price source's identifier for this game, kept after the first match. + /// Later refreshes look it up directly instead of repeating a fuzzy search, + /// which makes them both cheaper and stable — a title search that drifts to + /// a different edition next month would silently re-price the wrong thing. + /// + [MaxLength(100)] + public string? PriceSourceId { get; set; } + public decimal? ValueLoose { get; set; } public decimal? ValueCib { get; set; } public decimal? ValueNew { get; set; } diff --git a/backend/src/LudosData.Api/Services/Pricing/EbayPriceProvider.cs b/backend/src/LudosData.Api/Services/Pricing/EbayPriceProvider.cs index d4db639..dfc6681 100644 --- a/backend/src/LudosData.Api/Services/Pricing/EbayPriceProvider.cs +++ b/backend/src/LudosData.Api/Services/Pricing/EbayPriceProvider.cs @@ -35,7 +35,14 @@ public interface IPriceProvider { string Name { get; } bool IsConfigured { get; } - Task EstimateAsync(string title, string? system, CancellationToken ct = default); + + /// + /// Prices a game. is this provider's own + /// identifier from a previous match, when one is known — providers that can + /// use it should, since an exact lookup beats re-running a title search. + /// + Task EstimateAsync( + string title, string? system, string? sourceId = null, CancellationToken ct = default); } /// @@ -94,8 +101,12 @@ public class EbayPriceProvider( }; public async Task EstimateAsync( - string title, string? system, CancellationToken ct = default) + string title, string? system, string? sourceId = null, CancellationToken ct = default) { + // Browse has no stable per-product identifier to reuse; every lookup is + // a fresh search. + _ = sourceId; + if (!IsConfigured) { throw new InvalidOperationException( diff --git a/backend/src/LudosData.Api/Services/Pricing/PriceChartingProvider.cs b/backend/src/LudosData.Api/Services/Pricing/PriceChartingProvider.cs index 4dbb91d..936264f 100644 --- a/backend/src/LudosData.Api/Services/Pricing/PriceChartingProvider.cs +++ b/backend/src/LudosData.Api/Services/Pricing/PriceChartingProvider.cs @@ -70,7 +70,7 @@ public class PriceChartingProvider( } public async Task EstimateAsync( - string title, string? system, CancellationToken ct = default) + string title, string? system, string? sourceId = null, CancellationToken ct = default) { if (!IsConfigured) { @@ -79,9 +79,16 @@ public class PriceChartingProvider( } var client = httpClientFactory.CreateClient("pricecharting"); + + // An id from a previous match identifies the exact product; only fall + // back to searching by name when there is none. + var lookup = string.IsNullOrWhiteSpace(sourceId) + ? $"&q={Uri.EscapeDataString(BuildQuery(title, system))}" + : $"&id={Uri.EscapeDataString(sourceId)}"; + var url = "https://www.pricecharting.com/api/product" + $"?t={Uri.EscapeDataString(_options.Token)}" - + $"&q={Uri.EscapeDataString(BuildQuery(title, system))}"; + + lookup; using var response = await client.GetAsync(url, ct); if (!response.IsSuccessStatusCode) @@ -130,7 +137,33 @@ public class PriceChartingProvider( loose is null ? 0 : 1, cib is null ? 0 : 1, boxed is null ? 0 : 1, - 0); + 0) + { + // Carried through so a dry run can show which record was matched. + MatchedName = ReadString(root, "product-name", "productName"), + MatchedConsole = ReadString(root, "console-name", "consoleName"), + SourceId = ReadString(root, "id", "productId"), + }; + } + + private static string? ReadString(JsonElement root, params string[] names) + { + foreach (var name in names) + { + if (root.TryGetProperty(name, out var element)) + { + var value = element.ValueKind == JsonValueKind.String + ? element.GetString() + : element.ToString(); + + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + } + + return null; } /// Prices arrive as integer pennies; 1250 means $12.50. diff --git a/backend/src/LudosData.Api/Services/Pricing/PriceEstimate.cs b/backend/src/LudosData.Api/Services/Pricing/PriceEstimate.cs index 4665548..0ad400a 100644 --- a/backend/src/LudosData.Api/Services/Pricing/PriceEstimate.cs +++ b/backend/src/LudosData.Api/Services/Pricing/PriceEstimate.cs @@ -21,6 +21,25 @@ public record PriceEstimate( int NewSamples, int Discarded) { + /// + /// What the source thinks it priced, when it says so. + /// + /// Prices are meaningless without knowing which record they came from: a + /// lookup for the DS "Chrono Trigger" that quietly resolves to the SNES + /// original returns plausible numbers for the wrong game. Surfacing the + /// matched title and console makes a dry run auditable instead of a leap of + /// faith. + /// + public string? MatchedName { get; init; } + public string? MatchedConsole { get; init; } + + /// + /// The source's own identifier for the matched product, when it has one. + /// Storing it turns every later refresh into an exact lookup rather than a + /// repeat of the same fuzzy search. + /// + public string? SourceId { get; init; } + public bool HasAnyPrice => Loose is not null || Cib is not null || New is not null; public static readonly PriceEstimate Empty = new(null, null, null, 0, 0, 0, 0); diff --git a/backend/tests/LudosData.Api.Tests/PriceSourceTests.cs b/backend/tests/LudosData.Api.Tests/PriceSourceTests.cs index db239d7..a9b628b 100644 --- a/backend/tests/LudosData.Api.Tests/PriceSourceTests.cs +++ b/backend/tests/LudosData.Api.Tests/PriceSourceTests.cs @@ -367,3 +367,60 @@ public class PriceEndpointTests(LudosApiFactory factory) : IClassFixture Items, int Total); } + +public class PriceChartingMatchTests +{ + [Fact] + public void The_matched_product_and_console_are_reported() + { + using var document = JsonDocument.Parse(""" + { + "status": "success", + "id": "6910", + "product-name": "Chrono Trigger", + "console-name": "Super Nintendo", + "loose-price": 12800, "cib-price": 65000, "new-price": 1200000 + } + """); + + var estimate = PriceChartingProvider.Parse(document); + + // Without this a dry run cannot tell a DS entry that resolved to the + // SNES original from one that resolved correctly. + Assert.Equal("Chrono Trigger", estimate.MatchedName); + Assert.Equal("Super Nintendo", estimate.MatchedConsole); + Assert.Equal("6910", estimate.SourceId); + } + + [Fact] + public void A_numeric_id_is_read_as_a_string() + { + using var document = JsonDocument.Parse(""" + { "status": "success", "id": 6910, "loose-price": 100 } + """); + + Assert.Equal("6910", PriceChartingProvider.Parse(document).SourceId); + } + + [Fact] + public void Missing_match_metadata_is_not_fatal() + { + using var document = JsonDocument.Parse("""{ "loose-price": 12800 }"""); + + var estimate = PriceChartingProvider.Parse(document); + + Assert.Equal(128.00m, estimate.Loose); + Assert.Null(estimate.MatchedName); + Assert.Null(estimate.SourceId); + } + + [Fact] + public void A_stored_id_is_preferred_over_a_title_search() + { + // Documents the intent of the lookup switch: with an id, the query is an + // exact product fetch, so a drifting title search cannot re-price a + // different edition on a later run. + Assert.Equal("super nintendo Chrono Trigger", + PriceChartingProvider.BuildQuery("Chrono Trigger", "SNES")); + } +}