Files
marechai/Marechai.Igdb/Program.cs
Natalia Portillo 3080d20cbf feat: Add IGDB mapping pipeline (Marechai.Igdb)
Adds a new console project that maps IGDB's catalog (platforms, game
types, companies, games, involved companies) to existing local
Company/Software/SoftwarePlatform records, without any enrichment or
record creation. This establishes the cross-reference table a future
IGDB enrichment phase will consume, the same way Marechai.MobyGames
already does for MobyGames.

Database (Marechai.Database):
- New mirror+mapping tables: IgdbPlatform, IgdbGameType, IgdbCompany,
  IgdbGame, IgdbInvolvedCompany. Each stores only the IGDB id, name,
  and the few relationship fields needed for matching/disambiguation
  (no descriptive fields) plus a nullable FK to the local entity and
  an IgdbMatchStatus (Pending/Matched/NoMatch/NeedsReview/Skipped/
  Error). No columns were added to Company/Software/SoftwarePlatform,
  consistent with the existing per-source mapping convention used by
  MobyGamesImportState/WwpcSoftware/OldDosSoftware.
- IgdbGame.GameTypeId is a nullable FK into the new IgdbGameType
  lookup table rather than a baked-in enum: IGDB's game_type is
  itself a dynamic lookup entity (game.category is deprecated in
  favor of it).
- An initial design mirrored IGDB's external_games/external_game_
  sources to bridge straight into the existing MobyGames->Software
  mapping. Verified directly against the live API that IGDB does not
  track MobyGames as an external source at all, so that table/service
  and the matcher's bridge step were removed again (see the two
  paired migrations).
- Migrations: AddIgdbMirrorAndMappingTables, RemoveIgdbExternalGames.

Shared helpers promoted to Marechai.Data/Helpers (pure, no EF Core
dependency, usable from a console exe without pulling in the Blazor
Marechai project or duplicating logic):
- Marechai/Helpers/JaroWinkler.cs -> Marechai.Data/Helpers/JaroWinkler.cs
- Marechai.MobyGames/Services/SoundexHelper.cs -> Marechai.Data/Helpers/SoundexHelper.cs
All consumers (Marechai's Razor pages, Marechai.MobyGames's matchers)
updated to the new namespace.

Marechai.Igdb console project:
- IgdbAuthService: Twitch OAuth2 client-credentials flow with a
  cached, auto-refreshed access token.
- IgdbHttpClient: Apicalypse requests rate-limited to the configured
  requests/second with bounded concurrency and retry-with-backoff on
  HTTP 429.
- ApicalypseQueryBuilder: small fluent query builder.
- Mirror services (platforms, game types, companies, games, involved
  companies): each run mirrors exactly one batch of up to
  --batch-size rows (clamped to IGDB's 500-row page limit) and stops,
  matching the one-batch-per-invocation convention already used by
  Marechai.MobyGames's import command. Pass --batch-size 0 to instead
  loop until the whole remaining catalog is mirrored in one
  invocation. Resumption is fully automatic: each run re-derives its
  own cursor from the database (MAX(IgdbId) already mirrored, or
  row count for the small offset-paginated lookups) - no manual
  bookkeeping - and the --batch-size 0 loop tracks its cursor in
  memory so --dry-run never gets stuck reading the same unchanged
  page forever.
- PlatformMatcher: exact -> normalized -> substring -> Jaro-Winkler
  matching against SoftwarePlatform. Local platform names follow
  MobyGames-style short/compound conventions ("Jaguar", "DOS, Windows
  and Windows 3.x") while IGDB uses one full canonical name per
  platform, so each local platform is expanded into name variants
  (the compound parts) before matching, plus a whole-word substring
  pass to bridge short-vs-full names ("Jaguar" <-> "Atari Jaguar").
- CompanyMatcher: exact name -> exact legal name -> suffix-stripped
  exact (ported from Marechai.MobyGames's CompanyMatcher) -> Soundex
  bucket confirmed by Jaro-Winkler -> full Jaro-Winkler sweep. Unlike
  the MobyGames matcher, ambiguity never prompts or auto-creates: it
  is recorded as NeedsReview (with candidates) or NoMatch, since this
  pass only establishes mapping and is meant to run unattended across
  the full catalog. Soundex-bucket candidates are filtered to a 0.90
  Jaro-Winkler floor before being considered at all - an early version
  without this floor surfaced 29,906 NeedsReview rows out of 71,210
  mirrored companies because a shared 4-character Soundex code alone
  is weak evidence and collides constantly between unrelated short
  names; with the floor and tightened accept thresholds the same
  catalog produces 2,602 matched / 139 needs-review / 68,469 no-match.
- GameMatcher: maps IGDB games to local Software (the umbrella title;
  per-platform SoftwareRelease mapping is left for a future enrichment
  phase). Edition/bundle/remaster/port-type games inherit their
  parent's SoftwareId directly instead of being matched or created
  independently, reflecting that Marechai models these as
  SoftwareRelease rows under one Software while IGDB models them as
  separate game rows linked via parent_game/version_parent. Exact name
  matches are disambiguated by platform overlap; fuzzy matches require
  platform corroboration when the candidate already has releases.
  Verified on a 1,000-game sample that this correctly avoids merging
  sequels into their base title (Quake/Quake II, DOOM II/Doom 3,
  Metal Gear/Metal Gear Solid V, etc. all left as NeedsReview rather
  than falsely matched).
- MatchStatsService / AmbiguousReviewService: stats command and an
  optional, non-blocking interactive triage command for NeedsReview
  rows.
- CLI: mirror-platforms, mirror-game-types, mirror-companies,
  mirror-games, mirror-involved-companies, match-platforms,
  match-companies, match-games, stats, review-ambiguous, reset.
  All mirror/match commands support --dry-run.

.gitignore: added Marechai.Igdb/state/ (OAuth token cache), mirroring
the existing Marechai.MobyGames/state/ entry.
2026-06-25 21:50:09 +01:00

327 lines
11 KiB
C#

using System;
using System.Linq;
using System.Threading.Tasks;
using Marechai.Data;
using Marechai.Database;
using Marechai.Database.Models;
using Marechai.Igdb.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace Marechai.Igdb;
class Program
{
static async Task<int> Main(string[] args)
{
Console.WriteLine("\e[32;1mMarechai IGDB Mapping Tool\e[0m\n");
string environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Development";
var config = new ConfigurationBuilder().AddJsonFile("appsettings.json", optional: true)
.AddJsonFile($"appsettings.{environment}.json", optional: true)
.Build();
string marechaiConn = config.GetConnectionString("DefaultConnection");
if(string.IsNullOrEmpty(marechaiConn))
{
Console.WriteLine("\e[31;1mMissing connection string in appsettings.json\e[0m");
return 1;
}
var optionsBuilder = new DbContextOptionsBuilder<MarechaiContext>();
optionsBuilder.UseLazyLoadingProxies()
.AddMarechaiInterceptors()
.UseMySql(marechaiConn,
new MariaDbServerVersion(new Version(12, 0, 2)),
b => b.UseMicrosoftJson().EnableStringComparisonTranslations()
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
var factory = new MarechaiContextFactory(optionsBuilder.Options);
string command = args.Length > 0 ? args[0].ToLowerInvariant() : "help";
bool dryRun = args.Contains("--dry-run");
int batchSize = GetIntOption(args, "--batch-size", config.GetValue("Import:BatchSize", 500));
string clientId = config.GetValue<string>("Igdb:ClientId");
string clientSecret = config.GetValue<string>("Igdb:ClientSecret");
string tokenCachePath = config.GetValue("Igdb:TokenCachePath", "state/igdb-token.json");
int requestsPerSecond = config.GetValue("Igdb:RequestsPerSecond", 4);
int maxConcurrentRequests = config.GetValue("Igdb:MaxConcurrentRequests", 8);
switch(command)
{
case "mirror-platforms":
{
IgdbHttpClient client = RequireClient();
if(client == null)
return 1;
var service = new PlatformMirrorService(factory, client);
int total = await service.RunAsync(batchSize, dryRun);
Console.WriteLine($"\nDone. Mirrored {total} platforms.");
break;
}
case "mirror-game-types":
{
IgdbHttpClient client = RequireClient();
if(client == null)
return 1;
var service = new GameTypeMirrorService(factory, client);
int total = await service.RunAsync(batchSize, dryRun);
Console.WriteLine($"\nDone. Mirrored {total} game types.");
break;
}
case "mirror-companies":
{
IgdbHttpClient client = RequireClient();
if(client == null)
return 1;
var service = new CompanyMirrorService(factory, client);
int total = await service.RunAsync(batchSize, dryRun);
Console.WriteLine($"\nDone. Mirrored {total} companies.");
break;
}
case "mirror-games":
{
IgdbHttpClient client = RequireClient();
if(client == null)
return 1;
var service = new GameMirrorService(factory, client);
int total = await service.RunAsync(batchSize, dryRun);
Console.WriteLine($"\nDone. Mirrored {total} games.");
break;
}
case "mirror-involved-companies":
{
IgdbHttpClient client = RequireClient();
if(client == null)
return 1;
var service = new InvolvedCompanyMirrorService(factory, client);
int total = await service.RunAsync(batchSize, dryRun);
Console.WriteLine($"\nDone. Mirrored {total} involved companies.");
break;
}
case "match-platforms":
{
var matcher = new PlatformMatcher(factory);
(int matched, int needsReview) = await matcher.RunAsync(dryRun);
Console.WriteLine($"\nDone. Matched: {matched}, needs review: {needsReview}.");
break;
}
case "match-companies":
{
var matcher = new Services.CompanyMatcher(factory);
(int matched, int needsReview, int noMatch) = await matcher.RunAsync(dryRun);
Console.WriteLine($"\nDone. Matched: {matched}, needs review: {needsReview}, no match: {noMatch}.");
break;
}
case "match-games":
{
var matcher = new GameMatcher(factory);
(int matched, int needsReview, int noMatch) = await matcher.RunAsync(dryRun);
Console.WriteLine($"\nDone. Matched: {matched}, needs review: {needsReview}, no match: {noMatch}.");
break;
}
case "stats":
{
var stats = new MatchStatsService(factory);
await stats.PrintAsync();
break;
}
case "review-ambiguous":
{
string type = GetStringOption(args, "--type", null);
int limit = GetIntOption(args, "--limit", 20);
if(type == null)
{
Console.WriteLine(" --type companies|games|platforms is required.");
return 1;
}
var review = new AmbiguousReviewService(factory);
await review.RunAsync(type, limit);
break;
}
case "reset":
{
long? igdbId = long.TryParse(GetStringOption(args, "--igdb-id", null), out long id) ? id : null;
string type = GetStringOption(args, "--type", null);
if(igdbId == null || type == null)
{
Console.WriteLine(" --igdb-id <id> and --type companies|games|platforms are required.");
return 1;
}
await ResetAsync(factory, type, igdbId.Value);
break;
}
default:
PrintUsage();
break;
}
return 0;
IgdbHttpClient RequireClient()
{
if(string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret))
{
Console.WriteLine("\e[31;1mMissing Igdb:ClientId / Igdb:ClientSecret in appsettings.json\e[0m");
return null;
}
return IgdbHttpClient.Create(clientId, clientSecret, tokenCachePath, requestsPerSecond,
maxConcurrentRequests);
}
}
static async Task ResetAsync(MarechaiContextFactory factory, string type, long igdbId)
{
await using MarechaiContext context = await factory.CreateDbContextAsync();
switch(type)
{
case "platforms":
{
IgdbPlatform entity = await context.IgdbPlatforms.FirstOrDefaultAsync(p => p.Id == igdbId);
if(entity != null)
{
entity.MatchStatus = IgdbMatchStatus.Pending;
entity.SoftwarePlatformId = null;
entity.MatchType = null;
entity.MatchedOn = null;
}
break;
}
case "companies":
{
IgdbCompany entity = await context.IgdbCompanies.FirstOrDefaultAsync(c => c.IgdbId == igdbId);
if(entity != null)
{
entity.MatchStatus = IgdbMatchStatus.Pending;
entity.CompanyId = null;
entity.MatchType = null;
entity.MatchScore = null;
entity.MatchedOn = null;
entity.CandidatesJson = null;
}
break;
}
case "games":
{
IgdbGame entity = await context.IgdbGames.FirstOrDefaultAsync(g => g.IgdbId == igdbId);
if(entity != null)
{
entity.MatchStatus = IgdbMatchStatus.Pending;
entity.SoftwareId = null;
entity.MatchType = null;
entity.MatchScore = null;
entity.MatchedOn = null;
entity.CandidatesJson = null;
}
break;
}
default:
Console.WriteLine($" Unknown reset type \"{type}\".");
return;
}
await context.SaveChangesAsync();
Console.WriteLine($" Reset {type} IGDB id {igdbId} to Pending.");
}
static int GetIntOption(string[] args, string name, int defaultValue)
{
for(int i = 0; i < args.Length - 1; i++)
if(args[i] == name && int.TryParse(args[i + 1], out int value))
return value;
return defaultValue;
}
static string GetStringOption(string[] args, string name, string defaultValue)
{
for(int i = 0; i < args.Length - 1; i++)
if(args[i] == name)
return args[i + 1];
return defaultValue;
}
static void PrintUsage()
{
Console.WriteLine("""
Usage: dotnet Marechai.Igdb.dll <command> [options]
Commands:
mirror-platforms Pull IGDB /platforms
mirror-game-types Pull IGDB /game_types
mirror-companies Pull IGDB /companies (resumable)
mirror-games Pull IGDB /games (resumable)
mirror-involved-companies Pull IGDB /involved_companies (resumable)
match-platforms Match mirrored platforms against SoftwarePlatform
match-companies Match mirrored companies against Company
match-games Match mirrored games against Software
stats Print match status counts
review-ambiguous --type <companies|games|platforms> [--limit N]
reset --igdb-id <id> --type <companies|games|platforms>
Options:
--batch-size N Mirror one batch of up to N rows (default Import:BatchSize).
Pass 0 to mirror the entire remaining catalog in one run.
--dry-run Don't write to the database
""");
}
}