Files
marechai/Marechai.Igdb/Program.cs
Natalia Portillo d76dd99090 feat: Download missing SoftwarePlatform logos from matched IGDB platforms
Adds a platform logo enrichment stage to the IGDB pipeline (Marechai.Igdb),
mirroring the existing company enrichment flow but for SoftwarePlatform's
raster logo (LogoId/LogoExtension), which previously had to be uploaded
manually.

- IgdbPlatform gains LogoImageId, EnrichmentApplied, and EnrichedOn columns
  (migration AddIgdbPlatformLogoEnrichment), mirroring IgdbCompany's
  enrichment-tracking pattern.
- PlatformMirrorService now requests platform_logo.image_id from IGDB and
  stores it on insert and on re-mirror.
- New PlatformEnricherService: for platforms matched to an IGDB platform
  with no local logo yet, downloads the best available logo image and
  reproduces the exact originals/jpeg/webp/avif + 4k/thumbs layout and
  ImageMagick conversion parameters used by the manual logo upload
  endpoint (SoftwarePlatformsController.UploadLogoAsync), so enriched and
  manually-uploaded logos are indistinguishable on disk.
- New IgdbImageDownloader helper, shared between company and platform
  enrichment: IGDB's documented image sizes top out at logo_med for
  logo-type images, with no documented "original" size, and any size
  accepts an undocumented-but-functional "_2x" retina suffix. Rather than
  trusting any single size identifier, it downloads each plausible
  candidate (logo_med_2x, logo_med, original), decodes the actual pixel
  dimensions of each with `magick identify`, and keeps whichever is truly
  largest. CompanyEnricherService.CacheLogoAsync is updated to use this
  helper too, replacing its previous unconditional reliance on the
  undocumented t_original template.
- Program.cs: new `enrich-platforms` command (wired through a new
  AssetRootPath config key, since output goes straight into the real
  asset tree rather than a review cache), usage text, and a
  `platforms-enrichment` reset case to clear EnrichmentApplied/EnrichedOn
  for re-runs.

Marechai.Igdb intentionally does not take a project reference on
Marechai.Server (which owns the Photos helper and ASP.NET Core
dependencies) to perform these image conversions; the ImageMagick
subprocess invocation is duplicated in PlatformEnricherService to keep
the console tool's dependency footprint small.
2026-06-26 11:49:16 +01:00

416 lines
15 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 "mirror-alternative-names":
{
IgdbHttpClient client = RequireClient();
if(client == null)
return 1;
var service = new IgdbAlternativeNameMirrorService(factory, client);
int total = await service.RunAsync(batchSize, dryRun);
Console.WriteLine($"\nDone. Mirrored {total} alternative names.");
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 "enrich-companies":
{
string logoCachePath = config.GetValue("LogoCachePath", "state/igdb-logo-cache");
var enricher = new CompanyEnricherService(factory, logoCachePath);
CompanyEnricherService.Stats stats = await enricher.RunAsync(dryRun);
Console.WriteLine($"""
Done. Processed {stats.CompaniesProcessed} companies ({stats.CompaniesSkippedNoLocalMatch} skipped, no local match).
Countries filled: {stats.CountriesFilled}
Websites filled: {stats.WebsitesFilled}
Founded dates filled: {stats.FoundedFilled}
Status updated: {stats.StatusFilled}
Sold dates filled: {stats.SoldFilled}
SoldTo resolved: {stats.SoldToFilled}
Descriptions added: {stats.DescriptionsAdded}
Logos cached: {stats.LogosCached}
""");
break;
}
case "enrich-platforms":
{
string assetRootPath = config.GetValue<string>("AssetRootPath");
if(string.IsNullOrEmpty(assetRootPath))
{
Console.WriteLine("\e[31;1mMissing AssetRootPath in appsettings.json\e[0m");
return 1;
}
var enricher = new PlatformEnricherService(factory, assetRootPath);
PlatformEnricherService.Stats stats = await enricher.RunAsync(dryRun);
Console.WriteLine($"""
Done. Processed {stats.PlatformsProcessed} platforms ({stats.PlatformsSkippedNoLocalMatch} skipped, no local match).
Logos downloaded: {stats.LogosDownloaded}
Logos already present: {stats.LogosSkippedAlreadyPresent}
""");
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|companies-enrichment|games|platforms|platforms-enrichment 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 "companies-enrichment":
{
IgdbCompany entity = await context.IgdbCompanies.FirstOrDefaultAsync(c => c.IgdbId == igdbId);
if(entity != null)
{
entity.EnrichmentApplied = false;
entity.EnrichedOn = null;
}
break;
}
case "platforms-enrichment":
{
IgdbPlatform entity = await context.IgdbPlatforms.FirstOrDefaultAsync(p => p.Id == igdbId);
if(entity != null)
{
entity.EnrichmentApplied = false;
entity.EnrichedOn = 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)
mirror-alternative-names Pull IGDB /alternative_names (resumable)
match-platforms Match mirrored platforms against SoftwarePlatform
match-companies Match mirrored companies against Company
match-games Match mirrored games against Software
enrich-companies Fill empty Company fields from matched IGDB data (resumable)
enrich-platforms Download missing SoftwarePlatform logos from matched IGDB data (resumable)
stats Print match status counts
review-ambiguous --type <companies|games|platforms> [--limit N]
reset --igdb-id <id> --type <companies|companies-enrichment|games|platforms|platforms-enrichment>
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
""");
}
}