Publish Ludarium source
Public source validation / source (push) Successful in 2m16s

This commit is contained in:
Ludarium release export
2026-09-03 02:08:58 +02:00
commit df869819ce
339 changed files with 46455 additions and 0 deletions
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Ludarium.IntegrationTests")]
@@ -0,0 +1,70 @@
using Ludarium.Application;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace Ludarium.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddLudariumInfrastructure(this IServiceCollection services, string connectionString, string? exportRoot = null)
{
var database = new NpgsqlConnectionStringBuilder(connectionString)
{
MaxPoolSize = 50,
Timeout = 15,
CommandTimeout = 120
};
services.AddSingleton(NpgsqlDataSource.Create(database.ConnectionString));
services.AddSingleton<PostgresStore>();
services.AddSingleton<ILudariumStore>(provider => provider.GetRequiredService<PostgresStore>());
services.AddSingleton<ICatalogStore>(provider => provider.GetRequiredService<PostgresStore>());
services.AddSingleton<ILibraryExperienceStore>(provider => provider.GetRequiredService<PostgresStore>());
services.AddSingleton<IGameDataVaultStore>(provider => provider.GetRequiredService<PostgresStore>());
services.AddSingleton<IBrowserPlayStore>(provider => provider.GetRequiredService<PostgresStore>());
services.AddSingleton<IFirmwareStore>(provider => provider.GetRequiredService<PostgresStore>());
services.AddSingleton<IReadOnlyLibraryFileSystem, ReadOnlyLibraryFileSystem>();
services.AddSingleton<IScanCoordinator, ScanCoordinator>();
services.AddSingleton<ICatalogImporter, LogiqxCatalogImporter>();
services.AddSingleton<ISnapshotComparer, SnapshotComparer>();
services.AddSingleton<ISupportBundleService>(provider => new SupportBundleService(provider.GetRequiredService<ILudariumStore>(), exportRoot ?? Path.Combine(AppContext.BaseDirectory, "exports")));
services.AddSingleton<IInventoryExportService>(provider => new InventoryExportService(provider.GetRequiredService<ILudariumStore>(), exportRoot ?? Path.Combine(AppContext.BaseDirectory, "exports")));
services.AddSingleton<IGameCatalogExportService>(provider => new GameCatalogExportService(
provider.GetRequiredService<ILudariumStore>(), provider.GetRequiredService<ILibraryExperienceStore>(),
exportRoot ?? Path.Combine(AppContext.BaseDirectory, "exports")));
services.AddSingleton(new GameArtworkStore(Path.Combine(AppContext.BaseDirectory, "cache", "artwork")));
services.AddSingleton(new GameMediaStore(Path.Combine(AppContext.BaseDirectory, "cache", "media")));
services.AddSingleton(new GameDataVaultFileStore(Path.Combine(AppContext.BaseDirectory, "cache", "game-data")));
services.AddSingleton(new FirmwareVaultFileStore(Environment.GetEnvironmentVariable("LUDARIUM_FIRMWARE_ROOT")
?? Path.Combine(AppContext.BaseDirectory, "data", "firmware")));
services.AddSingleton(new GameCheatCatalog(Path.Combine(AppContext.BaseDirectory, "cache", "cheats")));
services.AddSingleton(new SwitchRuntimeProvisioner(Environment.GetEnvironmentVariable("LUDARIUM_SWITCH_KEYS_ROOT")));
services.AddSingleton<GameArtworkEnricher>();
services.AddSingleton<IGameMetadataProvider>(new IgdbMetadataProvider(CreateProviderClient(),
Environment.GetEnvironmentVariable("IGDB_CLIENT_ID"), Environment.GetEnvironmentVariable("IGDB_CLIENT_SECRET")));
services.AddSingleton<IGameMetadataProvider>(new MobyGamesMetadataProvider(CreateProviderClient(),
Environment.GetEnvironmentVariable("MOBYGAMES_API_KEY")));
services.AddSingleton<IGameMetadataProvider>(new ScreenScraperMetadataProvider(CreateProviderClient(),
Environment.GetEnvironmentVariable("SCREENSCRAPER_DEVID"), Environment.GetEnvironmentVariable("SCREENSCRAPER_DEVPASSWORD"),
Environment.GetEnvironmentVariable("SCREENSCRAPER_SOFTNAME"), Environment.GetEnvironmentVariable("SCREENSCRAPER_USER"),
Environment.GetEnvironmentVariable("SCREENSCRAPER_PASSWORD")));
services.AddSingleton<IGameMetadataProvider>(new RetroAchievementsMetadataProvider(CreateProviderClient(),
Environment.GetEnvironmentVariable("RETROACHIEVEMENTS_API_KEY"), Environment.GetEnvironmentVariable("RETROACHIEVEMENTS_USERNAME")));
services.AddSingleton<IGameProviderHub, GameProviderHub>();
services.AddSingleton<IBrowserPlayService>(provider => new NativeBrowserPlayService(
provider.GetRequiredService<IBrowserPlayStore>(), provider.GetRequiredService<ILudariumStore>(),
provider.GetRequiredService<IReadOnlyLibraryFileSystem>(),
File.Exists(Path.Combine(AppContext.BaseDirectory, "wwwroot", "emulatorjs", "data", "loader.js")),
int.TryParse(Environment.GetEnvironmentVariable("BROWSERPLAY_MAX_SESSIONS"), out var maxSessions) ? maxSessions : 2,
provider.GetRequiredService<IFirmwareStore>(), provider.GetRequiredService<FirmwareVaultFileStore>()));
services.AddSingleton<GameMetadataEnricher>();
services.AddSingleton<IGameDiscoveryService>(new RawgGameDiscoveryService(new HttpClient { Timeout = TimeSpan.FromSeconds(12) }, Environment.GetEnvironmentVariable("RAWG_API_KEY")));
return services;
}
private static HttpClient CreateProviderClient()
{
var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(20) };
client.DefaultRequestHeaders.UserAgent.ParseAdd(ReleaseIdentity.UserAgent);
return client;
}
}
@@ -0,0 +1,250 @@
using System.IO.Compression;
using System.IO.Pipelines;
using System.Text;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.Infrastructure;
/// <summary>One descriptor and the exact read-only files it names, in the order they are packed.</summary>
public sealed record DiscSet(string EntryPath, IReadOnlyList<string> MemberPaths, long TotalBytes)
{
public int DiscCount => MemberPaths.Count(BrowserPlayPolicy.IsCueSheet);
}
/// <summary>
/// Serves a descriptor-based disc dump to the browser player as a single streamed ZIP.
/// </summary>
/// <remarks>
/// CUE/BIN is the dominant PlayStation dump layout and M3U is how a multi-disc game names its discs.
/// Both were shapes browser play could not open: a session streams exactly one file, and a descriptor
/// on its own is a text file. Resolving the descriptor and building the archive as it is read keeps
/// every existing invariant — the source library is only ever read, nothing is written beside it, and
/// a set that cannot be fully resolved fails closed instead of playing half a game.
/// </remarks>
public static class DiscSetArchive
{
/// <summary>A descriptor is a small text file; anything larger is not one.</summary>
public const long MaximumDescriptorBytes = 256 * 1024;
/// <summary>Redump multi-track discs stay well below this; a larger set is not a single disc.</summary>
public const int MaximumTrackFiles = 32;
/// <summary>No retail game ships more discs than this, and each one costs a resolve.</summary>
public const int MaximumDiscs = 12;
private static readonly HashSet<string> TrackExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".bin", ".img", ".iso", ".raw", ".wav", ".mp3", ".ogg", ".flac", ".ape"
};
/// <summary>Per-disc entries a playlist may name. A playlist of playlists is refused.</summary>
private static readonly HashSet<string> DiscExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".cue", ".chd", ".iso", ".img", ".pbp", ".bin"
};
public static bool IsCueSheet(string? relativePath) => BrowserPlayPolicy.IsCueSheet(relativePath);
public static bool IsPlaylist(string? relativePath) => relativePath is not null &&
Path.GetExtension(relativePath).Equals(".m3u", StringComparison.OrdinalIgnoreCase);
public static bool IsDescriptor(string? relativePath) => IsCueSheet(relativePath) || IsPlaylist(relativePath);
/// <summary>
/// Read the track file names out of a CUE sheet.
/// </summary>
/// <remarks>
/// A CUE sheet is operator content from a read-only library, so it is parsed defensively: only
/// FILE lines are honoured, and a name that is anything other than a plain file beside the sheet
/// is rejected rather than resolved.
/// </remarks>
public static IReadOnlyList<string> ParseTrackFiles(string cueText)
{
var files = new List<string>();
foreach (var raw in cueText.Split('\n'))
{
var line = raw.Trim().TrimEnd('\r');
if (!line.StartsWith("FILE", StringComparison.OrdinalIgnoreCase) ||
(line.Length > 4 && !char.IsWhiteSpace(line[4]))) continue;
var name = ExtractFileName(line[4..]);
if (name is null) throw new InvalidDataException("A FILE entry in the CUE sheet has no readable name.");
if (!IsSafeMemberName(name, TrackExtensions))
throw new InvalidDataException($"The CUE sheet references an unsupported track file '{name}'.");
if (!files.Contains(name, StringComparer.OrdinalIgnoreCase)) files.Add(name);
if (files.Count > MaximumTrackFiles)
throw new InvalidDataException("The CUE sheet references more track files than one disc can hold.");
}
if (files.Count == 0) throw new InvalidDataException("The CUE sheet references no track files.");
return files;
}
/// <summary>
/// Read the disc entries out of an M3U playlist. Comments and blank lines are skipped; every
/// remaining line must name one plain disc file beside the playlist.
/// </summary>
public static IReadOnlyList<string> ParsePlaylist(string playlistText)
{
var discs = new List<string>();
foreach (var raw in playlistText.Split('\n'))
{
var line = raw.Trim().TrimEnd('\r');
if (line.Length == 0 || line.StartsWith('#')) continue;
if (!IsSafeMemberName(line, DiscExtensions))
throw new InvalidDataException($"The playlist references an unsupported disc entry '{line}'.");
if (!discs.Contains(line, StringComparer.OrdinalIgnoreCase)) discs.Add(line);
if (discs.Count > MaximumDiscs)
throw new InvalidDataException("The playlist references more discs than one game may hold.");
}
if (discs.Count == 0) throw new InvalidDataException("The playlist references no discs.");
return discs;
}
/// <summary>
/// Resolve a descriptor to its complete set, or return null when the set is incomplete, oversized
/// or unreadable. A partially resolvable game must never start.
/// </summary>
public static async Task<DiscSet?> ResolveAsync(IReadOnlyLibraryFileSystem files, LibraryRoot library,
string descriptorPath, long descriptorSize, long maximumBytes, CancellationToken ct)
{
if (!IsDescriptor(descriptorPath) || descriptorSize is <= 0 or > MaximumDescriptorBytes) return null;
try
{
var directory = DirectoryOf(descriptorPath);
var members = new List<string> { descriptorPath };
var total = descriptorSize;
if (IsCueSheet(descriptorPath))
{
if (await AddCueTracksAsync(files, library, descriptorPath, members, total, maximumBytes, ct)
is not { } cueTotal) return null;
return new(descriptorPath, members, cueTotal);
}
foreach (var disc in ParsePlaylist(await ReadTextAsync(files, library, descriptorPath, ct)))
{
var discPath = Combine(directory, disc);
var size = SizeOf(files, library, discPath);
if (size <= 0) return null;
members.Add(discPath);
total += size;
if (total > maximumBytes) return null;
// A playlist entry that is itself a CUE brings its own tracks along.
if (!IsCueSheet(discPath) || size > MaximumDescriptorBytes) continue;
if (await AddCueTracksAsync(files, library, discPath, members, total, maximumBytes, ct)
is not { } discTotal) return null;
total = discTotal;
}
return new(descriptorPath, members, total);
}
catch (Exception exception) when (exception is InvalidDataException or IOException or
UnauthorizedAccessException or InvalidOperationException or DecoderFallbackException)
{
return null;
}
}
/// <summary>
/// Stream the resolved set as a ZIP the browser core can mount. Entries are stored uncompressed:
/// disc tracks do not compress usefully and the player only pays for the copy.
/// </summary>
public static BrowserPlayContent Open(DiscSet set, IReadOnlyLibraryFileSystem files, LibraryRoot library,
CancellationToken ct)
{
var pipe = new Pipe();
_ = WriteAsync(pipe.Writer, set, files, library, ct);
var name = Path.ChangeExtension(BrowserPlayPolicy.SafeFileName(set.EntryPath), ".zip");
// The archive is produced as it is read, so its exact encoded length is not known up front.
return new(pipe.Reader.AsStream(), name, 0, null);
}
/// <summary>Add a CUE sheet's tracks to the set, or return null when the disc is not complete.</summary>
private static async Task<long?> AddCueTracksAsync(IReadOnlyLibraryFileSystem files, LibraryRoot library,
string cuePath, List<string> members, long total, long maximumBytes, CancellationToken ct)
{
var directory = DirectoryOf(cuePath);
foreach (var name in ParseTrackFiles(await ReadTextAsync(files, library, cuePath, ct)))
{
var trackPath = Combine(directory, name);
if (members.Contains(trackPath, StringComparer.OrdinalIgnoreCase)) continue;
var size = SizeOf(files, library, trackPath);
if (size <= 0) return null;
members.Add(trackPath);
total += size;
if (total > maximumBytes) return null;
}
return total;
}
private static async Task WriteAsync(PipeWriter writer, DiscSet set, IReadOnlyLibraryFileSystem files,
LibraryRoot library, CancellationToken ct)
{
Exception? failure = null;
try
{
await using var output = writer.AsStream(leaveOpen: true);
using var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true);
foreach (var member in set.MemberPaths)
{
var entry = archive.CreateEntry(BrowserPlayPolicy.SafeFileName(member), CompressionLevel.NoCompression);
await using var destination = entry.Open();
await using var source = await files.OpenReadAsync(library, member, ct);
await source.CopyToAsync(destination, 1024 * 1024, ct);
}
}
catch (Exception exception)
{
failure = exception;
}
// Completing with the failure surfaces a truncated game to the reader as an error rather than
// as a silently short archive.
await writer.CompleteAsync(failure);
}
private static async Task<string> ReadTextAsync(IReadOnlyLibraryFileSystem files, LibraryRoot library,
string relativePath, CancellationToken ct)
{
await using var content = await files.OpenReadAsync(library, relativePath, ct);
using var reader = new StreamReader(content, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
return await reader.ReadToEndAsync(ct);
}
private static long SizeOf(IReadOnlyLibraryFileSystem files, LibraryRoot library, string relativePath)
{
var info = new FileInfo(files.ResolveContainedPath(library, relativePath));
return info.Exists ? info.Length : 0;
}
private static string Combine(string directory, string name) =>
directory.Length == 0 ? name : $"{directory}/{name}";
private static string DirectoryOf(string relativePath)
{
var normalized = relativePath.Replace('\\', '/');
var index = normalized.LastIndexOf('/');
return index < 0 ? string.Empty : normalized[..index];
}
private static string? ExtractFileName(string remainder)
{
var trimmed = remainder.Trim();
if (trimmed.Length == 0) return null;
if (trimmed[0] == '"')
{
var closing = trimmed.IndexOf('"', 1);
return closing > 1 ? trimmed[1..closing] : null;
}
// An unquoted name ends at the track mode keyword that always follows it.
var end = trimmed.LastIndexOf(' ');
var name = end > 0 ? trimmed[..end].Trim() : trimmed;
return name.Length == 0 ? null : name;
}
/// <summary>A member must be one plain file beside its descriptor, with an expected extension.</summary>
private static bool IsSafeMemberName(string name, HashSet<string> extensions) =>
name.Length is > 0 and <= 200 &&
name.IndexOfAny(['/', '\\', ':', '\0']) < 0 &&
!name.Any(char.IsControl) &&
name is not "." and not ".." &&
extensions.Contains(Path.GetExtension(name));
}
@@ -0,0 +1,133 @@
using System.Security.Cryptography;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.Infrastructure;
public sealed record FirmwareReconciliationResult(int RemovedUploads, int RemovedOrphans, long ReclaimedBytes);
public sealed class FirmwareVaultFileStore
{
private readonly string root;
public FirmwareVaultFileStore(string root)
{
this.root = Path.GetFullPath(root);
Directory.CreateDirectory(this.root);
}
public async Task<FirmwareAsset> SaveAsync(FirmwareRequirement requirement, Guid id, string fileName,
Stream input, long? declaredLength, CancellationToken ct)
{
fileName = FirmwarePolicy.ValidateFileName(requirement, fileName);
if (declaredLength is > 0 && declaredLength > requirement.MaximumBytes)
throw new FirmwareValidationException($"{requirement.Label} exceeds its upload limit.");
var directory = ContainedPath($"{requirement.Platform}/{requirement.Slot}");
Directory.CreateDirectory(directory);
var location = $"{requirement.Platform}/{requirement.Slot}/{id:N}.bin";
var destination = ContainedPath(location);
var temporary = ContainedPath($"{requirement.Platform}/{requirement.Slot}/{id:N}.{Guid.NewGuid():N}.uploading");
try
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var header = new byte[16];
var headerLength = 0;
long length = 0;
await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write,
FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan))
{
var buffer = new byte[64 * 1024];
while (true)
{
var read = await input.ReadAsync(buffer, ct);
if (read == 0) break;
length += read;
if (length > requirement.MaximumBytes)
throw new FirmwareValidationException($"{requirement.Label} exceeds its upload limit.");
var copy = Math.Min(header.Length - headerLength, read);
if (copy > 0) { buffer.AsSpan(0, copy).CopyTo(header.AsSpan(headerLength)); headerLength += copy; }
hash.AppendData(buffer, 0, read);
await output.WriteAsync(buffer.AsMemory(0, read), ct);
}
await output.FlushAsync(ct);
}
if (length == 0) throw new FirmwareValidationException("Firmware and key files cannot be empty.");
RejectHostExecutableOrArchive(header.AsSpan(0, headerLength));
var sha256 = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
File.Move(temporary, destination, false);
var now = DateTimeOffset.UtcNow;
return new(id, requirement.Platform, requirement.Slot, requirement.Kind, fileName, length,
sha256, location, true, now, now);
}
finally
{
if (File.Exists(temporary)) File.Delete(temporary);
}
}
public async Task<FirmwareContent?> OpenAsync(FirmwareAsset asset, CancellationToken ct)
{
var path = ContainedPath(asset.Location);
if (!File.Exists(path)) return null;
var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
try
{
if (stream.Length != asset.Length)
throw new FirmwareIntegrityException("The app-owned firmware length no longer matches its evidence.");
var actual = Convert.ToHexString(await SHA256.HashDataAsync(stream, ct)).ToLowerInvariant();
if (!actual.Equals(asset.Sha256, StringComparison.Ordinal))
throw new FirmwareIntegrityException("The app-owned firmware checksum no longer matches its evidence.");
stream.Position = 0;
return new(stream, stream.Length, asset.Sha256);
}
catch { await stream.DisposeAsync(); throw; }
}
public bool Delete(FirmwareAsset asset)
{
var path = ContainedPath(asset.Location);
if (!File.Exists(path)) return false;
File.Delete(path);
return true;
}
public FirmwareReconciliationResult Reconcile(IReadOnlySet<string> knownLocations)
{
var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
var known = new HashSet<string>(knownLocations.Select(NormalizeRelative), comparer);
var uploads = 0;
var orphans = 0;
long bytes = 0;
var options = new EnumerationOptions { RecurseSubdirectories = true, AttributesToSkip = FileAttributes.ReparsePoint };
foreach (var path in Directory.EnumerateFiles(root, "*", options).ToArray())
{
var relative = NormalizeRelative(Path.GetRelativePath(root, path));
var upload = relative.EndsWith(".uploading", StringComparison.OrdinalIgnoreCase);
var orphan = relative.EndsWith(".bin", StringComparison.OrdinalIgnoreCase) && !known.Contains(relative);
if (!upload && !orphan) continue;
bytes += new FileInfo(path).Length;
File.Delete(path);
if (upload) uploads++; else orphans++;
}
return new(uploads, orphans, bytes);
}
private string ContainedPath(string relative)
{
if (Path.IsPathRooted(relative)) throw new FirmwareValidationException("Firmware locations must be app-relative.");
var path = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar)));
if (!path.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new FirmwareValidationException("The firmware location escapes the app-owned vault.");
return path;
}
private static string NormalizeRelative(string value) => value.Replace('\\', '/').TrimStart('/');
private static void RejectHostExecutableOrArchive(ReadOnlySpan<byte> header)
{
if (header.StartsWith("MZ"u8) || header.StartsWith("#!"u8) || header.StartsWith("PK\x03\x04"u8) ||
(header.Length >= 4 && header[..4].SequenceEqual(new byte[] { 0x7f, 0x45, 0x4c, 0x46 })))
throw new FirmwareValidationException("Host executables, scripts and archives are not accepted as firmware.");
}
}
@@ -0,0 +1,511 @@
using System.Collections.Concurrent;
using System.IO.Compression;
using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.Infrastructure;
public sealed record ArtworkEnrichmentResult(int Games, int AlreadyAvailable, int Downloaded, int Unmatched, int Failed);
internal sealed record RetailArtworkSource(string Url, string Provider, string MatchMethod, string ExternalId);
public sealed partial class GameArtworkEnricher(GameArtworkStore artworkStore, ILudariumStore store)
{
private static readonly HttpClient Client = new(new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(10),
AutomaticDecompression = DecompressionMethods.All
})
{
Timeout = TimeSpan.FromSeconds(20)
};
private static readonly SemaphoreSlim NetworkSlots = new(4, 4);
private static readonly SemaphoreSlim EnrichmentGate = new(1, 1);
private readonly ConcurrentDictionary<string, Task<IReadOnlyDictionary<string, string>>> indexes = new(StringComparer.OrdinalIgnoreCase);
private Task<IReadOnlyDictionary<string, string>>? launchBoxIndex;
private static readonly Dictionary<string, string> LibretroPlatforms = new(StringComparer.OrdinalIgnoreCase)
{
["nes"] = "Nintendo - Nintendo Entertainment System",
["snes"] = "Nintendo - Super Nintendo Entertainment System",
["n64"] = "Nintendo - Nintendo 64",
["gb"] = "Nintendo - Game Boy",
["gbc"] = "Nintendo - Game Boy Color",
["gba"] = "Nintendo - Game Boy Advance",
["nds"] = "Nintendo - Nintendo DS",
["3ds"] = "Nintendo - Nintendo 3DS",
["gamecube"] = "Nintendo - GameCube",
["wii"] = "Nintendo - Wii",
["wiiu"] = "Nintendo - Wii U",
["wii-u"] = "Nintendo - Wii U",
["psx"] = "Sony - PlayStation",
["ps2"] = "Sony - PlayStation 2",
["psp"] = "Sony - PlayStation Portable",
["psvita"] = "Sony - PlayStation Vita",
["ps4"] = "Sony - PlayStation 4",
["xbox"] = "Microsoft - Xbox",
["xbox360"] = "Microsoft - Xbox 360",
["xbox-360"] = "Microsoft - Xbox 360"
};
private static readonly Dictionary<string, string> LaunchBoxPlatforms = new(StringComparer.OrdinalIgnoreCase)
{
["windows"] = "Windows",
["ps5"] = "Sony Playstation 5",
["ps4"] = "Sony Playstation 4",
["psvita"] = "Sony Playstation Vita",
["psp"] = "Sony PSP",
["ps2"] = "Sony Playstation 2",
["psx"] = "Sony Playstation",
["switch"] = "Nintendo Switch",
["wiiu"] = "Nintendo Wii U",
["wii-u"] = "Nintendo Wii U",
["wii"] = "Nintendo Wii",
["gamecube"] = "Nintendo GameCube",
["3ds"] = "Nintendo 3DS",
["nds"] = "Nintendo DS",
["n64"] = "Nintendo 64",
["gba"] = "Nintendo Game Boy Advance",
["gbc"] = "Nintendo Game Boy Color",
["gb"] = "Nintendo Game Boy",
["snes"] = "Super Nintendo Entertainment System",
["nes"] = "Nintendo Entertainment System",
["xbox"] = "Microsoft Xbox",
["xbox360"] = "Microsoft Xbox 360",
["xbox-360"] = "Microsoft Xbox 360"
};
private static readonly Dictionary<string, string> ArtworkLookupAliases = new(StringComparer.Ordinal)
{
["callofdutymw2cr"] = "Call of Duty Modern Warfare 2 Campaign Remastered",
["callofdutyblackops6ps5internalps5b"] = "Call of Duty Black Ops 6",
["dishonoredcollection"] = "Dishonored",
["disneysabugslife"] = "A Bug's Life",
["destroyallhumans2"] = "Destroy All Humans 2 Reprobed",
["fable3"] = "Fable III",
["legolotr"] = "LEGO The Lord of the Rings",
["legopirates"] = "LEGO Pirates of the Caribbean The Video Game",
["lifeisstrangereunionps5unlimited"] = "Life is Strange Reunion",
["marioluigibowsersinsidestorybowsersjr"] = "Mario & Luigi Bowser's Inside Story",
["pokemonheartgold"] = "Pokemon HeartGold Version",
["pokemonred"] = "Pokemon Red Version",
["pokemonsoulsilver"] = "Pokemon SoulSilver Version",
["pokemonyellowversion"] = "Pokemon Yellow Version Special Pikachu Edition",
["spidermanmilesmorales"] = "Marvel's Spider-Man Miles Morales",
["spidermanremastered"] = "Marvel's Spider-Man Remastered",
["thejakanddaxtertrilogy"] = "The Jak and Daxter Collection",
["thewalkingdeadseries"] = "The Walking Dead The Telltale Definitive Series",
["tetrispsn"] = "Tetris",
["tropico6tropicanshores"] = "Tropico 6",
["watchdogs2"] = "Watch Dogs 2"
};
private static readonly Dictionary<string, (string Image, string Product, string Provider, string MatchMethod)> CuratedArtwork = new(StringComparer.Ordinal)
{
["ps5\0asterixandobelixslapthemall"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_asterixobelixslapthemalle.jpg", "https://www.wog.ch/en/index.cfm/details/product/163417-Asterix-Obelix-Slap-them-All", "WorldOfGames", "curated-retailer-platform-box-front"),
["ps5\0callofdutyblackops6"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_callofdutyblackops6e.jpg", "https://www.wog.ch/en/index.cfm/details/product/205943-Call-of-Duty-Black-Ops-6-EN", "WorldOfGames", "curated-retailer-platform-box-front"),
["ps5\0destroyallhumans2reprobed"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_destroyallhumans2reprobede.jpg", "https://www.wog.ch/en/index.cfm/details/product/213841-Destroy-All-Humans-2-Reprobed", "WorldOfGames", "curated-retailer-platform-box-front"),
["ps5\0evilgenius2worlddomination"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_evilgenius2worlddominatione.jpg", "https://www.wog.ch/en/index.cfm/details/product/179758-Evil-Genius-2-World-Domination", "WorldOfGames", "curated-retailer-platform-box-front"),
["ps5\0lifeisstrangereunion"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_lifeisstrangereunion.jpg", "https://www.wog.ch/en/index.cfm/details/product/240467-Life-is-Strange-Reunion", "WorldOfGames", "curated-retailer-platform-box-front"),
["ps5\0pragmata"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_pragmatad.jpg", "https://www.wog.ch/en/index.cfm/details/product/248802-PRAGMATA", "WorldOfGames", "curated-retailer-platform-box-front"),
["ps5\0rollercoastertycoonadventures"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_rollercoastertycoonadventuresdeluxee.jpg", "https://www.wog.ch/en/index.cfm/details/product/184680-RollerCoaster-Tycoon-Adventures-Deluxe", "WorldOfGames", "curated-retailer-platform-box-front"),
["ps5\0twopointmuseum"] = ("https://www.wog.ch/nas/cover_xl/p5/p5_twopointmuseumexploreredition.jpg", "https://www.wog.ch/en/index.cfm/details/product/210671-Two-Point-Museum-Explorer-Edition", "WorldOfGames", "curated-retailer-platform-box-front"),
["psx\0abugslife"] = ("https://psxdatacenter.com/images/hires/U/D/SCUS-94288/SCUS-94288-F-ALL.jpg", "https://psxdatacenter.com/games/U/D/SCUS-94288.html", "PSXDataCenter", "curated-serial-platform-box-front"),
["windows\0moviestycoon"] = ("https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2659050/library_600x900_2x.jpg", "https://store.steampowered.com/app/2659050/Movies_Tycoon/", "SteamDigital", "verified-digital-only-storefront-front")
};
public async Task<ArtworkEnrichmentResult> EnrichAsync(
IReadOnlyList<Game> games,
IReadOnlyList<Release> releases,
CancellationToken cancellationToken,
Func<int, int, Task>? progress = null)
{
await EnrichmentGate.WaitAsync(cancellationToken);
try
{
var byGame = releases.GroupBy(release => release.GameId).ToDictionary(group => group.Key, group => group.ToArray());
var already = 0;
var downloaded = 0;
var unmatched = 0;
var failed = 0;
foreach (var game in games)
{
cancellationToken.ThrowIfCancellationRequested();
var platforms = byGame.GetValueOrDefault(game.Id)?
.Select(item => item.Platform)
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item => item!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? [];
var anySelected = false;
var anyDownloaded = false;
var anyFailure = false;
foreach (var platform in platforms)
{
GameArtwork? selected = await store.GetSelectedGameArtworkAsync(game.Id, cancellationToken, platform);
if (selected is not null)
{
var evaluated = ArtworkQuality.EvaluateForPlatform(selected, platform);
if (evaluated != selected) await store.UpsertGameArtworkAsync(evaluated, cancellationToken);
selected = evaluated;
if (await artworkStore.OpenAsync(game.Id, cancellationToken, selected.Id, fallbackToCurrent: false) is { } existing)
await existing.Content.DisposeAsync();
else selected = null;
if (selected?.Source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase) == true)
{
anySelected = true;
continue;
}
if (selected?.QualityScore >= ArtworkQuality.VerifiedThreshold)
{
anySelected = true;
continue;
}
if (selected is not null && selected.QualityScore == 0)
{
await store.UpsertGameArtworkAsync(selected with { Selected = false }, cancellationToken);
selected = null;
}
}
try
{
var excludedProviders = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var candidates = new List<GameArtwork>();
var candidateFailure = false;
while (excludedProviders.Count < 4)
{
var source = await FindAsync(game.Title, platform, cancellationToken, excludedProviders);
if (source is null) break;
excludedProviders.Add(source.Provider);
try
{
await using var content = await DownloadAsync(source.Url, cancellationToken);
await artworkStore.SaveAsync(game.Id, content, content.Length, cancellationToken, source.Provider);
if (await artworkStore.DescribeAsync(game.Id, source.Provider,
source.MatchMethod, Confidence.High, cancellationToken, platform, source.ExternalId) is { } artwork)
{
var candidate = ArtworkQuality.EvaluateForPlatform(artwork with { Selected = false }, platform);
await store.UpsertGameArtworkAsync(candidate, cancellationToken);
candidates.Add(candidate);
if (candidate.QualityScore >= ArtworkQuality.VerifiedThreshold) break;
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch { candidateFailure = true; }
}
var best = ArtworkQuality.SelectBest(candidates.Append(selected));
if (best is not null)
{
await store.SelectGameArtworkAsync(game.Id, best.Id, cancellationToken);
anySelected = true;
anyDownloaded |= candidates.Count > 0;
}
else if (candidateFailure) anyFailure = true;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch { anyFailure = true; }
}
if (anyDownloaded) downloaded++;
else if (anySelected) already++;
else if (anyFailure) failed++;
else unmatched++;
if (progress is not null) await progress(already + downloaded + unmatched + failed, games.Count);
}
return new(games.Count, already, downloaded, unmatched, failed);
}
finally { EnrichmentGate.Release(); }
}
private async Task<RetailArtworkSource?> FindAsync(string title, string? platform, CancellationToken cancellationToken,
HashSet<string>? excludedProviders = null)
{
excludedProviders ??= new HashSet<string>(StringComparer.OrdinalIgnoreCase);
title = ArtworkLookupTitle(title);
var curatedSource = FindCuratedRetailArtwork(title, platform);
if (!excludedProviders.Contains("WorldOfGames") && curatedSource is not null) return curatedSource;
if (!excludedProviders.Contains("Libretro") && platform is not null && LibretroPlatforms.TryGetValue(platform, out var playlist))
{
var index = await indexes.GetOrAdd(playlist, key => LoadLibretroIndexAsync(key, cancellationToken));
var normalizedTitle = Normalize(title);
if (!index.TryGetValue(normalizedTitle, out var file))
{
file = index
.Where(candidate => IsLikelyMatch(normalizedTitle, candidate.Key))
.OrderByDescending(candidate => Similarity(normalizedTitle, candidate.Key))
.Select(candidate => candidate.Value)
.FirstOrDefault();
}
if (file is not null)
{
var root = $"https://thumbnails.libretro.com/{Uri.EscapeDataString(playlist).Replace("%20", "%20")}/Named_Boxarts/";
var url = root + Uri.EscapeDataString(file).Replace("%2F", "/");
return new(url, "Libretro", "provider-title-platform-box-front", url);
}
}
if (!excludedProviders.Contains("Nintendo") && platform is ("switch" or "3ds" or "wii"))
{
var nintendo = await FindNintendoAsync(title, platform, cancellationToken);
if (nintendo is not null) return new(nintendo.Value.Url, nintendo.Value.Provider,
"provider-title-platform-box-front", nintendo.Value.Url);
}
if (!excludedProviders.Contains("LaunchBox") && platform is not null && LaunchBoxPlatforms.TryGetValue(platform, out var launchBoxPlatform))
{
launchBoxIndex ??= LoadLaunchBoxIndexAsync(cancellationToken);
var launchBox = await launchBoxIndex;
var launchBoxPrefix = launchBoxPlatform + "\0";
var launchBoxTitle = Normalize(title);
if (!launchBox.TryGetValue(launchBoxPrefix + launchBoxTitle, out var image))
{
image = launchBox
.Where(candidate => candidate.Key.StartsWith(launchBoxPrefix, StringComparison.Ordinal) &&
IsLikelyMatch(launchBoxTitle, candidate.Key[launchBoxPrefix.Length..]))
.OrderByDescending(candidate => Similarity(launchBoxTitle, candidate.Key[launchBoxPrefix.Length..]))
.Select(candidate => candidate.Value)
.FirstOrDefault();
}
if (image is not null)
{
var url = $"https://gamesdb-images.launchbox.gg/{Uri.EscapeDataString(image)}";
return new(url, "LaunchBox", "provider-title-platform-box-front", url);
}
}
return null;
}
internal static RetailArtworkSource? FindCuratedRetailArtwork(string title, string? platform)
{
if (platform is null) return null;
var lookupTitle = ArtworkLookupTitle(title);
return CuratedArtwork.TryGetValue(platform.ToLowerInvariant() + "\0" + Normalize(lookupTitle), out var curated)
? new(curated.Image, curated.Provider, curated.MatchMethod, curated.Product)
: null;
}
private async Task<IReadOnlyDictionary<string, string>> LoadLaunchBoxIndexAsync(CancellationToken cancellationToken)
{
var catalogDirectory = Path.Combine(Path.GetDirectoryName(artworkStore.DirectoryPath)!, "catalogs");
Directory.CreateDirectory(catalogDirectory);
var catalogPath = Path.Combine(catalogDirectory, "launchbox-metadata.zip");
if (!File.Exists(catalogPath) || new FileInfo(catalogPath).Length is < 1_000_000 or > 128 * 1024 * 1024)
await DownloadCatalogAsync("https://gamesdb.launchbox-app.com/Metadata.zip", catalogPath, 128 * 1024 * 1024, cancellationToken);
using var archive = ZipFile.OpenRead(catalogPath);
var entry = archive.GetEntry("Metadata.xml") ?? throw new InvalidDataException("LaunchBox metadata entry is missing.");
if (entry.Length > 650L * 1024 * 1024 || entry.CompressedLength == 0 || entry.Length / entry.CompressedLength > 20)
throw new InvalidDataException("LaunchBox metadata exceeds its extraction safety limits.");
await using var input = entry.Open();
using var reader = XmlReader.Create(input, new XmlReaderSettings { Async = true, DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null });
var platformNames = LaunchBoxPlatforms.Values.ToHashSet(StringComparer.OrdinalIgnoreCase);
var games = new Dictionary<int, string>();
var images = new Dictionary<string, (int Priority, string File)>(StringComparer.Ordinal);
while (await reader.ReadAsync())
{
cancellationToken.ThrowIfCancellationRequested();
if (reader.NodeType != XmlNodeType.Element || reader.Name is not ("Game" or "GameImage")) continue;
var elementName = reader.Name;
using var subtree = reader.ReadSubtree();
string? name = null, platform = null, type = null, file = null;
int id = 0;
while (subtree.Read())
{
if (subtree.NodeType != XmlNodeType.Element) continue;
switch (subtree.Name)
{
case "Name": name = subtree.ReadElementContentAsString(); break;
case "Platform": platform = subtree.ReadElementContentAsString(); break;
case "DatabaseID":
if (!int.TryParse(subtree.ReadElementContentAsString(), out id)) id = 0;
break;
case "Type": type = subtree.ReadElementContentAsString(); break;
case "FileName": file = subtree.ReadElementContentAsString(); break;
}
}
if (elementName == "Game" && id > 0 && name is not null && platform is not null && platformNames.Contains(platform))
games[id] = platform + "\0" + Normalize(name);
else if (elementName == "GameImage" && id > 0 && file is not null && type is not null && games.TryGetValue(id, out var key))
{
var priority = type switch { "Box - Front" => 0, "Box - Front - Reconstructed" => 1, "Box - 3D" => 2, _ => 99 };
if (priority < 99 && (!images.TryGetValue(key, out var current) || priority < current.Priority)) images[key] = (priority, file);
}
}
return images.ToDictionary(pair => pair.Key, pair => pair.Value.File, StringComparer.Ordinal);
}
private static async Task DownloadCatalogAsync(string url, string destination, long maximumBytes, CancellationToken cancellationToken)
{
var temporary = destination + ".tmp";
await NetworkSlots.WaitAsync(cancellationToken);
try
{
using var response = await Client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength is long length && length > maximumBytes) throw new InvalidDataException("Artwork catalog exceeds its download limit.");
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var output = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 64 * 1024, FileOptions.Asynchronous);
var buffer = new byte[64 * 1024];
while (true)
{
var read = await input.ReadAsync(buffer, cancellationToken);
if (read == 0) break;
if (output.Length + read > maximumBytes) throw new InvalidDataException("Artwork catalog exceeds its download limit.");
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
}
await output.FlushAsync(cancellationToken);
File.Move(temporary, destination, true);
}
finally { NetworkSlots.Release(); if (File.Exists(temporary)) File.Delete(temporary); }
}
private static async Task<(string Url, string Provider)?> FindNintendoAsync(string title, string platform, CancellationToken cancellationToken)
{
var query = Uri.EscapeDataString(SearchTitle(title));
var url = $"https://search.nintendo-europe.com/en/select?q={query}&fq=type%3AGAME&rows=12&wt=json";
await NetworkSlots.WaitAsync(cancellationToken);
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd(ReleaseIdentity.UserAgent);
using var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
var json = await ReadTextBoundedAsync(response.Content, 2 * 1024 * 1024, cancellationToken);
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("response", out var result) ||
!result.TryGetProperty("docs", out var items)) return null;
var wanted = Normalize(title);
foreach (var item in items.EnumerateArray())
{
if (!item.TryGetProperty("title", out var name) || !IsLikelyMatch(wanted, Normalize(name.GetString() ?? ""))) continue;
if (!MatchesNintendoPlatform(item, platform)) continue;
if (item.TryGetProperty("image_url", out var image) && image.GetString() is { Length: > 0 } cover)
return (cover, "Nintendo");
}
return null;
}
finally { NetworkSlots.Release(); }
}
private static bool MatchesNintendoPlatform(JsonElement item, string platform)
{
if (!item.TryGetProperty("system_names_txt", out var systems) || systems.ValueKind != JsonValueKind.Array) return platform == "switch";
var expected = platform switch { "switch" => "Nintendo Switch", "3ds" => "Nintendo 3DS", "wii" => "Wii", _ => "" };
return systems.EnumerateArray().Any(value => string.Equals(value.GetString(), expected, StringComparison.OrdinalIgnoreCase));
}
private static async Task<IReadOnlyDictionary<string, string>> LoadLibretroIndexAsync(string playlist, CancellationToken cancellationToken)
{
var url = $"https://thumbnails.libretro.com/{Uri.EscapeDataString(playlist)}/Named_Boxarts/";
await NetworkSlots.WaitAsync(cancellationToken);
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd(ReleaseIdentity.UserAgent);
using var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var html = await ReadTextBoundedAsync(response.Content, 32 * 1024 * 1024, cancellationToken);
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (Match match in ArtworkLinkRegex().Matches(html))
{
var file = Uri.UnescapeDataString(WebUtility.HtmlDecode(match.Groups[1].Value));
var key = Normalize(Path.GetFileNameWithoutExtension(file));
if (key.Length > 2) result.TryAdd(key, file);
}
return result;
}
finally { NetworkSlots.Release(); }
}
private static async Task<MemoryStream> DownloadAsync(string url, CancellationToken cancellationToken)
{
await NetworkSlots.WaitAsync(cancellationToken);
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd(ReleaseIdentity.UserAgent);
using var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength is > GameArtworkStore.MaximumBytes) throw new ArtworkValidationException("Remote artwork exceeds the 10 MiB limit.");
var output = new MemoryStream();
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
var buffer = new byte[64 * 1024];
while (true)
{
var read = await input.ReadAsync(buffer, cancellationToken);
if (read == 0) break;
if (output.Length + read > GameArtworkStore.MaximumBytes) throw new ArtworkValidationException("Remote artwork exceeds the 10 MiB limit.");
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
}
output.Position = 0;
return output;
}
finally { NetworkSlots.Release(); }
}
private static async Task<string> ReadTextBoundedAsync(HttpContent content, int maximumBytes, CancellationToken cancellationToken)
{
if (content.Headers.ContentLength is long length && length > maximumBytes) throw new ArtworkValidationException("Remote artwork index exceeds its safety limit.");
await using var input = await content.ReadAsStreamAsync(cancellationToken);
using var output = new MemoryStream();
var buffer = new byte[64 * 1024];
while (true)
{
var read = await input.ReadAsync(buffer, cancellationToken);
if (read == 0) break;
if (output.Length + read > maximumBytes) throw new ArtworkValidationException("Remote artwork index exceeds its safety limit.");
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
}
return System.Text.Encoding.UTF8.GetString(output.GetBuffer(), 0, checked((int)output.Length));
}
internal static string Normalize(string value) => NonAlphaNumericRegex().Replace(EditionNoiseRegex().Replace(CanonicalTitle(value), " ").ToLowerInvariant(), "");
internal static string ArtworkLookupTitle(string value)
{
var searched = SearchTitle(value);
var normalized = Normalize(searched);
if (normalized.StartsWith("marioluigibowsersinsidestory", StringComparison.Ordinal))
return "Mario & Luigi Bowser's Inside Story + Bowser Jr.'s Journey";
return ArtworkLookupAliases.GetValueOrDefault(normalized, searched);
}
internal static bool IsLikelyMatch(string wanted, string candidate)
{
if (wanted == candidate) return true;
if (wanted.Length < 8 || candidate.Length < 8) return false;
return Similarity(wanted, candidate) >= 0.82d;
}
private static double Similarity(string left, string right) =>
1d - (double)EditDistance(left, right) / Math.Max(left.Length, right.Length);
private static int EditDistance(string left, string right)
{
var previous = new int[right.Length + 1];
var current = new int[right.Length + 1];
for (var index = 0; index <= right.Length; index++) previous[index] = index;
for (var row = 1; row <= left.Length; row++)
{
current[0] = row;
for (var column = 1; column <= right.Length; column++)
{
var substitution = previous[column - 1] + (left[row - 1] == right[column - 1] ? 0 : 1);
current[column] = Math.Min(Math.Min(previous[column] + 1, current[column - 1] + 1), substitution);
}
(previous, current) = (current, previous);
}
return previous[right.Length];
}
private static string SearchTitle(string value) => SeparatorRegex().Replace(EditionNoiseRegex().Replace(CanonicalTitle(value), " "), " ").Trim();
private static string CanonicalTitle(string value) => PlatformSuffixRegex().Replace(BracketedSuffixRegex().Replace(value, " "), "").Replace("™", "").Replace("®", "").Trim();
[GeneratedRegex("href=\"([^\"]+\\.(?:png|jpg|jpeg|webp))\"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex ArtworkLinkRegex();
[GeneratedRegex("[\\(\\[].*?[\\)\\]]", RegexOptions.CultureInvariant)]
private static partial Regex BracketedSuffixRegex();
[GeneratedRegex("\\s+(?:PS[2345]|PSP|PSV|PS Vita|Switch|NDS|PC)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex PlatformSuffixRegex();
[GeneratedRegex("\\b(?:complete|deluxe|ultimate|definitive|standard|gold|collector'?s?|game of the year|goty|edition|incl(?:uding)? dlc|dlc unlocker|ce|usa|eur|europe|ntsc-u|clean|multi\\d*)\\b|\\b(?:NDS|PSV|PSP)-[a-z0-9]+\\b|\\bv\\d+(?:[ ._]\\d+)*|\\b(?:PPSA|CUSA)\\d+.*$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex EditionNoiseRegex();
[GeneratedRegex("\\s*[-–—_:]+\\s*", RegexOptions.CultureInvariant)]
private static partial Regex SeparatorRegex();
[GeneratedRegex("[^a-z0-9]", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex NonAlphaNumericRegex();
}
@@ -0,0 +1,183 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.Infrastructure;
public sealed record StoredGameArtwork(Stream Content, string ContentType, long Length);
public sealed class GameArtworkStore
{
public const long MaximumBytes = 10 * 1024 * 1024;
internal string DirectoryPath => root;
private readonly string root;
public GameArtworkStore(string root)
{
this.root = Path.GetFullPath(root);
Directory.CreateDirectory(this.root);
}
public async Task<StoredGameArtwork?> OpenAsync(Guid gameId, CancellationToken cancellationToken, Guid? artworkId = null, bool fallbackToCurrent = true)
{
var candidate = artworkId is null ? null : CandidatePathFor(gameId, artworkId.Value);
var path = candidate is not null && File.Exists(candidate) ? candidate : fallbackToCurrent ? PathFor(gameId) : candidate;
if (!File.Exists(path)) return null;
var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
var signature = new byte[12];
var read = await stream.ReadAsync(signature, cancellationToken);
stream.Position = 0;
var contentType = Detect(signature.AsSpan(0, read));
if (contentType is null)
{
await stream.DisposeAsync();
return null;
}
return new(stream, contentType, stream.Length);
}
public async Task<string> SaveAsync(Guid gameId, Stream content, long? declaredLength, CancellationToken cancellationToken, string source = "Uploaded")
{
if (declaredLength is > MaximumBytes) throw new ArtworkValidationException("Artwork exceeds the 10 MiB limit.");
var destination = PathFor(gameId);
var temporary = destination + ".upload-" + Guid.NewGuid().ToString("N");
try
{
await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan))
{
var buffer = new byte[64 * 1024];
long total = 0;
while (true)
{
var read = await content.ReadAsync(buffer, cancellationToken);
if (read == 0) break;
total += read;
if (total > MaximumBytes) throw new ArtworkValidationException("Artwork exceeds the 10 MiB limit.");
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
}
if (total == 0) throw new ArtworkValidationException("Artwork is empty.");
}
var signature = new byte[12];
string? contentType;
await using (var input = new FileStream(temporary, FileMode.Open, FileAccess.Read, FileShare.Read))
{
var read = await input.ReadAsync(signature, cancellationToken);
contentType = Detect(signature.AsSpan(0, read));
if (contentType is null) throw new ArtworkValidationException("Use a valid JPEG, PNG or WebP image.");
}
File.Move(temporary, destination, true);
await File.WriteAllTextAsync(SourcePathFor(gameId), source, cancellationToken);
return contentType;
}
finally
{
if (File.Exists(temporary)) File.Delete(temporary);
}
}
public bool Delete(Guid gameId)
{
var path = PathFor(gameId);
if (!File.Exists(path)) return false;
File.Delete(path);
var sourcePath = SourcePathFor(gameId);
if (File.Exists(sourcePath)) File.Delete(sourcePath);
return true;
}
public int DeleteAll(Guid gameId)
{
var deleted = Delete(gameId) ? 1 : 0;
var candidates = Path.Combine(root, "candidates", gameId.ToString("N"));
if (Directory.Exists(candidates))
{
deleted += Directory.EnumerateFiles(candidates).Count();
Directory.Delete(candidates, true);
}
return deleted;
}
public string GetSource(Guid gameId)
{
var path = SourcePathFor(gameId);
if (!File.Exists(path)) return "Uploaded";
var source = File.ReadAllText(path).Trim();
return source.Length is > 0 and <= 40 ? source : "Uploaded";
}
public async Task<GameArtwork?> DescribeAsync(Guid gameId, string? provider, string matchMethod,
Confidence confidence, CancellationToken cancellationToken, string? platform = null, string? externalId = null)
{
var path = PathFor(gameId);
if (!File.Exists(path)) return null;
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
var dimensions = await ReadDimensionsAsync(stream, cancellationToken);
stream.Position = 0;
var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken)).ToLowerInvariant();
var source = GetSource(gameId);
decimal? ratio = dimensions.Width is not null && dimensions.Height is > 0
? Math.Round((decimal)dimensions.Width.Value / dimensions.Height.Value, 4)
: null;
var score = ArtworkQuality.Score(dimensions.Width, dimensions.Height, ratio, source, confidence, platform);
var uploaded = source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase);
var verified = uploaded || score >= ArtworkQuality.VerifiedThreshold;
// Candidate identity is content-addressed. Re-running enrichment must not create a
// fresh review candidate for the same bytes and source every time.
var identity = System.Text.Encoding.UTF8.GetBytes($"{gameId:N}|{platform ?? "global"}|{hash}|{source}|{provider ?? source}");
var artworkId = new Guid(SHA256.HashData(identity)[..16]);
var candidatePath = CandidatePathFor(gameId, artworkId);
Directory.CreateDirectory(Path.GetDirectoryName(candidatePath)!);
File.Copy(path, candidatePath, true);
return new(artworkId, gameId,
uploaded ? ArtworkRole.UserUpload : verified ? ArtworkRole.VerifiedBoxFront : ArtworkRole.BoxFront,
uploaded ? ArtworkVerificationStatus.ManuallyVerified : verified ? ArtworkVerificationStatus.AutomaticallyVerified : ArtworkVerificationStatus.NeedsReview,
source, provider ?? source, externalId, matchMethod, confidence, dimensions.Width, dimensions.Height, ratio,
stream.Length, hash, score, DateTimeOffset.UtcNow, Platform: platform);
}
internal static async Task<(int? Width, int? Height)> ReadDimensionsAsync(Stream stream, CancellationToken ct)
{
var header = new byte[32];
var read = await stream.ReadAsync(header, ct);
if (read >= 24 && Detect(header.AsSpan(0, read)) == "image/png")
return (BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(16, 4)), BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(20, 4)));
if (read >= 30 && Detect(header.AsSpan(0, read)) == "image/webp" && header.AsSpan(12, 4).SequenceEqual("VP8X"u8))
return (1 + header[24] + (header[25] << 8) + (header[26] << 16), 1 + header[27] + (header[28] << 8) + (header[29] << 16));
if (read >= 3 && Detect(header.AsSpan(0, read)) == "image/jpeg")
{
stream.Position = 2;
var marker = new byte[4];
while (await stream.ReadAsync(marker.AsMemory(0, 4), ct) == 4)
{
if (marker[0] != 0xff) break;
var length = BinaryPrimitives.ReadUInt16BigEndian(marker.AsSpan(2, 2));
if (length < 2) break;
if (marker[1] is >= 0xc0 and <= 0xc3 or >= 0xc5 and <= 0xc7 or >= 0xc9 and <= 0xcb or >= 0xcd and <= 0xcf)
{
var size = new byte[5];
if (await stream.ReadAsync(size, ct) != 5) break;
return (BinaryPrimitives.ReadUInt16BigEndian(size.AsSpan(3, 2)), BinaryPrimitives.ReadUInt16BigEndian(size.AsSpan(1, 2)));
}
stream.Seek(length - 2, SeekOrigin.Current);
}
}
return (null, null);
}
private string PathFor(Guid gameId) => Path.Combine(root, gameId.ToString("N") + ".artwork");
private string CandidatePathFor(Guid gameId, Guid artworkId) => Path.Combine(root, "candidates", gameId.ToString("N"), artworkId.ToString("N") + ".artwork");
private string SourcePathFor(Guid gameId) => Path.Combine(root, gameId.ToString("N") + ".source");
internal static string? Detect(ReadOnlySpan<byte> bytes)
{
if (bytes.Length >= 3 && bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff) return "image/jpeg";
if (bytes.Length >= 8 && bytes[..8].SequenceEqual(new byte[] { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a })) return "image/png";
if (bytes.Length >= 12 && bytes[..4].SequenceEqual("RIFF"u8) && bytes[8..12].SequenceEqual("WEBP"u8)) return "image/webp";
return null;
}
}
public sealed class ArtworkValidationException(string message) : Exception(message);
@@ -0,0 +1,196 @@
using System.Text;
using System.Text.Json;
using System.Xml;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.Infrastructure;
public sealed class GameCatalogExportService(
ILudariumStore store,
ILibraryExperienceStore experienceStore,
string exportRoot) : IGameCatalogExportService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly string exportRoot = Path.GetFullPath(exportRoot);
public async Task<GameCatalogExportResult> CreateAsync(string format, CancellationToken cancellationToken)
{
var normalized = format.Trim().ToLowerInvariant() switch
{
"json" or "ludarium" => "json",
"emulationstation" or "gamelist" or "xml" => "emulationstation",
"pegasus" or "metadata.pegasus.txt" => "pegasus",
_ => throw new ArgumentException("Format must be json, emulationstation or pegasus.", nameof(format))
};
Directory.CreateDirectory(exportRoot);
var createdAt = DateTimeOffset.UtcNow;
var stamp = createdAt.ToString("yyyyMMdd-HHmmss-fff", System.Globalization.CultureInfo.InvariantCulture);
var fileName = normalized switch
{
"emulationstation" => $"gamelist-{stamp}.xml",
"pegasus" => $"metadata.pegasus-{stamp}.txt",
_ => $"game-catalog-{stamp}.json"
};
var path = ContainedPath(fileName);
try
{
var count = normalized switch
{
"json" => await WriteJsonAsync(path, createdAt, cancellationToken),
"emulationstation" => await WriteEmulationStationAsync(path, cancellationToken),
_ => await WritePegasusAsync(path, cancellationToken)
};
return new(fileName, normalized, count, createdAt);
}
catch
{
if (File.Exists(path)) File.Delete(path);
throw;
}
}
public Task<GameCatalogExportFile?> OpenAsync(string fileName, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName))
return Task.FromResult<GameCatalogExportFile?>(null);
var path = ContainedPath(fileName);
if (!File.Exists(path)) return Task.FromResult<GameCatalogExportFile?>(null);
var contentType = Path.GetExtension(path).ToLowerInvariant() switch
{
".json" => "application/json",
".xml" => "application/xml",
_ => "text/plain; charset=utf-8"
};
var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
return Task.FromResult<GameCatalogExportFile?>(new(stream, contentType, stream.Length, fileName));
}
private async Task<long> WriteJsonAsync(string path, DateTimeOffset createdAt, CancellationToken cancellationToken)
{
await using var output = NewOutput(path);
using var writer = new Utf8JsonWriter(output, new JsonWriterOptions { Indented = true });
writer.WriteStartObject();
writer.WriteString("schemaVersion", "1");
writer.WriteString("applicationVersion", ReleaseIdentity.Version);
writer.WriteString("createdAt", createdAt);
writer.WriteBoolean("sourceLibraryMutated", false);
writer.WriteStartArray("games");
long count = 0;
await foreach (var game in ReadGamesAsync(cancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
writer.WriteStartObject();
writer.WritePropertyName("game"); JsonSerializer.Serialize(writer, game, JsonOptions);
writer.WritePropertyName("releases"); JsonSerializer.Serialize(writer, await store.ListReleasesAsync(game.Id, cancellationToken), JsonOptions);
writer.WritePropertyName("claims"); JsonSerializer.Serialize(writer, await store.ListClaimsAsync("Game", game.Id, cancellationToken), JsonOptions);
writer.WritePropertyName("tags"); JsonSerializer.Serialize(writer, await experienceStore.ListGameTagsAsync(game.Id, cancellationToken), JsonOptions);
writer.WritePropertyName("state"); JsonSerializer.Serialize(writer, await experienceStore.GetGameUserStateAsync(game.Id, cancellationToken), JsonOptions);
writer.WritePropertyName("relations"); JsonSerializer.Serialize(writer, await experienceStore.ListGameRelationsAsync(game.Id, cancellationToken), JsonOptions);
writer.WritePropertyName("media"); JsonSerializer.Serialize(writer, await experienceStore.ListGameMediaAsync(game.Id, cancellationToken), JsonOptions);
writer.WritePropertyName("achievements"); JsonSerializer.Serialize(writer, await experienceStore.GetAchievementProgressAsync(game.Id, cancellationToken), JsonOptions);
writer.WriteEndObject();
count++;
if (count % 50 == 0) await writer.FlushAsync(cancellationToken);
}
writer.WriteEndArray();
writer.WriteEndObject();
await writer.FlushAsync(cancellationToken);
return count;
}
private async Task<long> WriteEmulationStationAsync(string path, CancellationToken cancellationToken)
{
await using var output = NewOutput(path);
await using var writer = XmlWriter.Create(output, new XmlWriterSettings
{
Async = true,
Encoding = new UTF8Encoding(false),
Indent = true,
CloseOutput = false
});
await writer.WriteStartDocumentAsync();
await writer.WriteCommentAsync("Metadata-only Ludarium export. ludarium:// paths never execute or mutate source content.");
await writer.WriteStartElementAsync(null, "gameList", null);
long count = 0;
await foreach (var game in ReadGamesAsync(cancellationToken))
{
var claims = await store.ListClaimsAsync("Game", game.Id, cancellationToken);
var effective = claims.GroupBy(item => item.Field, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => ClaimPolicy.SelectEffective(group)!.Value, StringComparer.OrdinalIgnoreCase);
await writer.WriteStartElementAsync(null, "game", null);
await writer.WriteElementStringAsync(null, "path", null, $"ludarium://game/{game.Id}");
await writer.WriteElementStringAsync(null, "name", null, effective.GetValueOrDefault("officialTitle") ?? game.Title);
await WriteOptionalElementAsync(writer, "desc", effective.GetValueOrDefault("description"));
await WriteOptionalElementAsync(writer, "releasedate", NormalizeExportDate(effective.GetValueOrDefault("releaseDate")));
await WriteOptionalElementAsync(writer, "developer", effective.GetValueOrDefault("developer"));
await WriteOptionalElementAsync(writer, "publisher", effective.GetValueOrDefault("publisher"));
await WriteOptionalElementAsync(writer, "genre", effective.GetValueOrDefault("genre"));
await writer.WriteEndElementAsync();
count++;
}
await writer.WriteEndElementAsync();
await writer.WriteEndDocumentAsync();
await writer.FlushAsync();
return count;
}
private async Task<long> WritePegasusAsync(string path, CancellationToken cancellationToken)
{
await using var output = NewOutput(path);
await using var writer = new StreamWriter(output, new UTF8Encoding(false));
await writer.WriteLineAsync("# Metadata-only Ludarium export; ludarium:// files are identifiers, not executable paths.");
long count = 0;
await foreach (var game in ReadGamesAsync(cancellationToken))
{
var claims = await store.ListClaimsAsync("Game", game.Id, cancellationToken);
var effective = claims.GroupBy(item => item.Field, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => ClaimPolicy.SelectEffective(group)!.Value, StringComparer.OrdinalIgnoreCase);
await writer.WriteLineAsync($"game: {SingleLine(effective.GetValueOrDefault("officialTitle") ?? game.Title)}");
await writer.WriteLineAsync($"file: ludarium://game/{game.Id}");
if (effective.GetValueOrDefault("description") is { } description)
await writer.WriteLineAsync($"description: {SingleLine(description)}");
if (effective.GetValueOrDefault("developer") is { } developer)
await writer.WriteLineAsync($"developer: {SingleLine(developer)}");
if (effective.GetValueOrDefault("publisher") is { } publisher)
await writer.WriteLineAsync($"publisher: {SingleLine(publisher)}");
if (effective.GetValueOrDefault("genre") is { } genre)
await writer.WriteLineAsync($"genre: {SingleLine(genre)}");
await writer.WriteLineAsync();
count++;
}
return count;
}
private async IAsyncEnumerable<Game> ReadGamesAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
const int pageSize = 200;
for (var pageNumber = 1; ; pageNumber++)
{
var page = await store.SearchGamesAsync(null, null, pageNumber, pageSize, cancellationToken);
foreach (var game in page.Items) yield return game;
if (page.Items.Count == 0 || (long)pageNumber * pageSize >= page.Total) yield break;
}
}
private string ContainedPath(string fileName)
{
var path = Path.GetFullPath(Path.Combine(exportRoot, fileName));
if (!path.StartsWith(exportRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Export path escapes the app-owned export directory.", nameof(fileName));
return path;
}
private static FileStream NewOutput(string path) => new(path, FileMode.CreateNew, FileAccess.Write,
FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
private static async Task WriteOptionalElementAsync(XmlWriter writer, string name, string? value)
{
if (!string.IsNullOrWhiteSpace(value)) await writer.WriteElementStringAsync(null, name, null, value);
}
private static string? NormalizeExportDate(string? value) => DateTimeOffset.TryParse(value, out var date)
? date.ToString("yyyyMMdd'T'HHmmss", System.Globalization.CultureInfo.InvariantCulture) : null;
private static string SingleLine(string value) => value.Replace("\r", " ", StringComparison.Ordinal)
.Replace("\n", " ", StringComparison.Ordinal).Trim();
}
@@ -0,0 +1,222 @@
using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
using Ludarium.Application;
namespace Ludarium.Infrastructure;
public sealed record GameCheat(string Name, string Code);
public sealed record GameCheatSet(bool Available, string State, string Message, string Source,
string Version, IReadOnlyList<GameCheat> Cheats);
public sealed record GameCheatCatalogStatus(bool Cached, string Source, string Version,
int IndexedFiles, DateTimeOffset? UpdatedAt);
/// <summary>
/// A bounded, app-owned index of the CC-BY-SA Libretro cheat database. Matching is deliberately
/// exact after title normalization; regional ambiguity returns no codes instead of guessing.
/// </summary>
public sealed partial class GameCheatCatalog : IDisposable
{
public const string Source = "Libretro Database";
public const string Version = "v1.21.1";
public const string Commit = "ccb2dca5d04b0a44aa0a6eb7489a71bfc66419a8";
public static readonly IReadOnlyList<string> BrowserPlatforms =
["nes", "snes", "gb", "gbc", "gba", "n64", "nds", "psx"];
private const int MaximumIndexBytes = 16 * 1024 * 1024;
private const int MaximumCheatBytes = 1024 * 1024;
private readonly string root;
private readonly string indexPath;
private readonly HttpClient client;
private readonly bool ownsClient;
private readonly SemaphoreSlim refreshGate = new(1, 1);
private IReadOnlyList<CheatIndexEntry>? index;
private static readonly Dictionary<string, string> PlatformFolders = new(StringComparer.OrdinalIgnoreCase)
{
["nes"] = "Nintendo - Nintendo Entertainment System",
["snes"] = "Nintendo - Super Nintendo Entertainment System",
["gb"] = "Nintendo - Game Boy",
["gbc"] = "Nintendo - Game Boy Color",
["gba"] = "Nintendo - Game Boy Advance",
["n64"] = "Nintendo - Nintendo 64",
["nds"] = "Nintendo - Nintendo DS",
["psx"] = "Sony - PlayStation"
};
public GameCheatCatalog(string root, HttpClient? client = null)
{
this.root = Path.GetFullPath(root);
indexPath = Path.Combine(this.root, $"libretro-{Version}-{Commit[..12]}.json");
ownsClient = client is null;
this.client = client ?? new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{
Timeout = TimeSpan.FromSeconds(30)
};
this.client.DefaultRequestHeaders.UserAgent.ParseAdd(ReleaseIdentity.UserAgent);
}
public async Task<GameCheatCatalogStatus> StatusAsync(CancellationToken ct)
{
var entries = await LoadIndexAsync(false, ct);
return new(File.Exists(indexPath), Source, Version, entries?.Count ?? 0,
File.Exists(indexPath) ? File.GetLastWriteTimeUtc(indexPath) : null);
}
public async Task<GameCheatCatalogStatus> RefreshAsync(CancellationToken ct)
{
await LoadIndexAsync(true, ct);
return await StatusAsync(ct);
}
public async Task<GameCheatSet> FindAsync(string title, string platform, CancellationToken ct)
{
if (!PlatformFolders.TryGetValue(platform, out var folder))
return Unavailable("UnsupportedPlatform", "Automatic cheats are not integrated for this player yet.");
var entries = await LoadIndexAsync(false, ct) ?? await LoadIndexAsync(true, ct) ?? [];
var wanted = GameArtworkEnricher.Normalize(title);
var matches = entries.Where(entry => entry.Folder.Equals(folder, StringComparison.OrdinalIgnoreCase) &&
GameArtworkEnricher.Normalize(Path.GetFileNameWithoutExtension(entry.Path)).Equals(wanted, StringComparison.Ordinal))
.ToArray();
if (matches.Length == 0)
return Unavailable("NotFound", "No exact title match exists in the cached Libretro cheat database.");
if (matches.Length > 1)
return Unavailable("Ambiguous", "Multiple regional cheat files match this title; Ludarium will not guess.");
var entry = matches[0];
var cachePath = Path.Combine(root, "files", entry.Sha + ".cht");
if (!File.Exists(cachePath)) await DownloadCheatAsync(entry, cachePath, ct);
var length = new FileInfo(cachePath).Length;
if (length is <= 0 or > MaximumCheatBytes)
return Unavailable("Invalid", "The matched cheat file failed its bounded size check.");
var cheats = Parse(await File.ReadAllTextAsync(cachePath, ct));
return cheats.Count == 0
? Unavailable("Empty", "The matched cheat file did not contain usable codes.")
: new(true, "Available", $"{cheats.Count} cheats matched by exact platform and title.", Source, Version, cheats);
}
public static IReadOnlyList<GameCheat> Parse(string content)
{
var descriptions = new Dictionary<int, string>();
var codes = new Dictionary<int, string>();
foreach (var line in content.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var match = CheatLine().Match(line.Trim());
if (!match.Success || !int.TryParse(match.Groups[1].Value, out var number) || number is < 0 or >= 1000) continue;
var value = match.Groups[3].Value.Trim();
if (match.Groups[2].Value == "desc" && value.Length is > 0 and <= 200) descriptions[number] = value;
if (match.Groups[2].Value == "code" && value.Length is > 0 and <= 8192) codes[number] = value;
}
return descriptions.Keys.Intersect(codes.Keys).Order().Take(500)
.Select(number => new GameCheat(descriptions[number], codes[number])).ToArray();
}
private async Task<IReadOnlyList<CheatIndexEntry>?> LoadIndexAsync(bool refresh, CancellationToken ct)
{
if (!refresh && index is not null) return index;
await refreshGate.WaitAsync(ct);
try
{
if (!refresh && index is not null) return index;
Directory.CreateDirectory(root);
if (!refresh && !File.Exists(indexPath)) return null;
if (refresh) await DownloadIndexAsync(ct);
await using var stream = File.OpenRead(indexPath);
index = await JsonSerializer.DeserializeAsync<CheatIndexEntry[]>(stream, cancellationToken: ct) ?? [];
return index;
}
finally { refreshGate.Release(); }
}
private async Task DownloadIndexAsync(CancellationToken ct)
{
var url = $"https://api.github.com/repos/libretro/libretro-database/git/trees/{Commit}?recursive=1";
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
if (response.StatusCode != HttpStatusCode.OK) throw new HttpRequestException("The pinned Libretro cheat index is unavailable.");
if (response.Content.Headers.ContentLength is > MaximumIndexBytes) throw new InvalidDataException("The cheat index exceeds its size limit.");
await using var input = await response.Content.ReadAsStreamAsync(ct);
using var output = new MemoryStream();
var buffer = new byte[64 * 1024];
while (true)
{
var read = await input.ReadAsync(buffer, ct);
if (read == 0) break;
if (output.Length + read > MaximumIndexBytes) throw new InvalidDataException("The cheat index exceeds its size limit.");
await output.WriteAsync(buffer.AsMemory(0, read), ct);
}
output.Position = 0;
using var document = await JsonDocument.ParseAsync(output, cancellationToken: ct);
var rootElement = document.RootElement;
if (!rootElement.GetProperty("sha").GetString()!.Equals(Commit, StringComparison.OrdinalIgnoreCase) ||
rootElement.GetProperty("truncated").GetBoolean())
throw new InvalidDataException("The cheat index did not match the pinned complete Git tree.");
var allowedFolders = PlatformFolders.Values.ToHashSet(StringComparer.OrdinalIgnoreCase);
var entries = new List<CheatIndexEntry>();
foreach (var item in rootElement.GetProperty("tree").EnumerateArray())
{
if (item.GetProperty("type").GetString() != "blob" || item.GetProperty("size").GetInt64() is <= 0 or > MaximumCheatBytes) continue;
var path = item.GetProperty("path").GetString() ?? "";
var parts = path.Split('/');
if (parts.Length != 3 || parts[0] != "cht" || !allowedFolders.Contains(parts[1]) ||
!parts[2].EndsWith(".cht", StringComparison.OrdinalIgnoreCase)) continue;
var sha = item.GetProperty("sha").GetString() ?? "";
if (sha.Length == 40 && sha.All(Uri.IsHexDigit)) entries.Add(new(parts[1], path, sha.ToLowerInvariant()));
}
if (entries.Count < 100) throw new InvalidDataException("The pinned cheat index did not contain the expected platform coverage.");
var temporary = indexPath + ".tmp";
try
{
await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(entries), ct);
File.Move(temporary, indexPath, true);
}
finally
{
if (File.Exists(temporary)) File.Delete(temporary);
}
index = entries;
}
private async Task DownloadCheatAsync(CheatIndexEntry entry, string destination, CancellationToken ct)
{
var encoded = string.Join('/', entry.Path.Split('/').Select(Uri.EscapeDataString));
using var response = await client.GetAsync($"https://raw.githubusercontent.com/libretro/libretro-database/{Commit}/{encoded}",
HttpCompletionOption.ResponseHeadersRead, ct);
if (response.StatusCode != HttpStatusCode.OK) throw new HttpRequestException("The matched cheat file is unavailable.");
if (response.Content.Headers.ContentLength is > MaximumCheatBytes) throw new InvalidDataException("The cheat file exceeds its size limit.");
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
var temporary = destination + ".tmp";
try
{
await using (var input = await response.Content.ReadAsStreamAsync(ct))
await using (var output = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 64 * 1024, FileOptions.Asynchronous))
{
var buffer = new byte[32 * 1024];
while (true)
{
var read = await input.ReadAsync(buffer, ct);
if (read == 0) break;
if (output.Length + read > MaximumCheatBytes) throw new InvalidDataException("The cheat file exceeds its size limit.");
await output.WriteAsync(buffer.AsMemory(0, read), ct);
}
}
File.Move(temporary, destination, true);
}
finally
{
if (File.Exists(temporary)) File.Delete(temporary);
}
}
private static GameCheatSet Unavailable(string state, string message) =>
new(false, state, message, Source, Version, []);
private sealed record CheatIndexEntry(string Folder, string Path, string Sha);
public void Dispose()
{
refreshGate.Dispose();
if (ownsClient) client.Dispose();
}
[GeneratedRegex("^cheat(\\d+)_(desc|code)\\s*=\\s*\"(.*)\"$", RegexOptions.CultureInvariant)]
private static partial Regex CheatLine();
}
@@ -0,0 +1,226 @@
using System.Security.Cryptography;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.Infrastructure;
public sealed record StoredGameDataRevision(Stream Content, long Length);
public sealed record GameDataReconciliationResult(int RemovedUploads, int RemovedOrphans, long ReclaimedBytes);
public sealed class GameDataVaultFileStore
{
public const long MaximumBytes = 64L * 1024 * 1024;
private readonly string root;
public GameDataVaultFileStore(string root)
{
this.root = Path.GetFullPath(root);
Directory.CreateDirectory(this.root);
}
public async Task<GameDataRevision> SaveAsync(Guid gameId, Guid entryId, Guid revisionId,
string fileName, Stream input, long? declaredLength, string actor, CancellationToken ct,
string sourceType = "ManualUpload", string? sourceId = null)
{
fileName = GameDataVaultPolicy.ValidateFileName(fileName);
if (declaredLength is > MaximumBytes)
throw new GameDataVaultValidationException("Game data exceeds the 64 MiB upload limit.");
var directory = ContainedPath($"{gameId:N}/{entryId:N}");
Directory.CreateDirectory(directory);
var relative = $"{gameId:N}/{entryId:N}/{revisionId:N}.bin";
var destination = ContainedPath(relative);
var temporary = ContainedPath($"{gameId:N}/{entryId:N}/{revisionId:N}.{Guid.NewGuid():N}.uploading");
try
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var header = new byte[16];
var headerLength = 0;
long length = 0;
await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write,
FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan))
{
var buffer = new byte[64 * 1024];
while (true)
{
var read = await input.ReadAsync(buffer, ct);
if (read == 0) break;
length += read;
if (length > MaximumBytes)
throw new GameDataVaultValidationException("Game data exceeds the 64 MiB upload limit.");
var headerBytes = Math.Min(header.Length - headerLength, read);
if (headerBytes > 0)
{
buffer.AsSpan(0, headerBytes).CopyTo(header.AsSpan(headerLength));
headerLength += headerBytes;
}
hash.AppendData(buffer, 0, read);
await output.WriteAsync(buffer.AsMemory(0, read), ct);
}
await output.FlushAsync(ct);
}
if (length == 0) throw new GameDataVaultValidationException("Game data is empty.");
RejectExecutableContent(header.AsSpan(0, headerLength));
var sha256 = Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
if (File.Exists(destination))
{
var existing = await HashExistingAsync(destination, ct);
if (existing.Length != length || !existing.Sha256.Equals(sha256, StringComparison.Ordinal))
throw new ResourceConflictException("The revision id is already used by different content.");
}
else
{
try
{
File.Move(temporary, destination, false);
}
catch (IOException) when (File.Exists(destination))
{
var existing = await HashExistingAsync(destination, ct);
if (existing.Length != length || !existing.Sha256.Equals(sha256, StringComparison.Ordinal))
throw new ResourceConflictException("The revision id is already used by different content.");
}
}
return new(revisionId, entryId, 0, fileName, "application/octet-stream", length, sha256,
relative, sourceType, sourceId ?? actor, DateTimeOffset.UtcNow);
}
finally
{
if (File.Exists(temporary)) File.Delete(temporary);
}
}
public async Task<StoredGameDataRevision?> OpenAsync(GameDataRevision revision, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var path = ContainedPath(revision.Location);
if (!File.Exists(path)) return null;
var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
try
{
if (stream.Length != revision.Length)
throw new GameDataIntegrityException("The stored game-data length no longer matches its revision evidence.");
var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream, ct)).ToLowerInvariant();
if (!hash.Equals(revision.Sha256, StringComparison.Ordinal))
throw new GameDataIntegrityException("The stored game-data checksum no longer matches its revision evidence.");
stream.Position = 0;
return new(stream, stream.Length);
}
catch
{
await stream.DisposeAsync();
throw;
}
}
public bool Delete(GameDataRevision revision)
{
var path = ContainedPath(revision.Location);
if (!File.Exists(path)) return false;
File.Delete(path);
RemoveEmptyParents(Path.GetDirectoryName(path));
return true;
}
public int Delete(IEnumerable<GameDataRevision> revisions)
{
var count = 0;
foreach (var revision in revisions)
if (Delete(revision)) count++;
return count;
}
public GameDataReconciliationResult Reconcile(IReadOnlySet<string> knownLocations)
{
var normalizedKnown = new HashSet<string>(knownLocations.Select(NormalizeRelative),
OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
var uploads = 0;
var orphans = 0;
long bytes = 0;
var options = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = false,
AttributesToSkip = FileAttributes.ReparsePoint,
ReturnSpecialDirectories = false
};
foreach (var path in Directory.EnumerateFiles(root, "*", options).ToArray())
{
var relative = NormalizeRelative(Path.GetRelativePath(root, path));
var upload = relative.EndsWith(".uploading", StringComparison.OrdinalIgnoreCase);
var orphan = relative.EndsWith(".bin", StringComparison.OrdinalIgnoreCase) &&
!normalizedKnown.Contains(relative);
if (!upload && !orphan) continue;
var length = new FileInfo(path).Length;
File.Delete(path);
RemoveEmptyParents(Path.GetDirectoryName(path));
bytes += length;
if (upload) uploads++; else orphans++;
}
return new(uploads, orphans, bytes);
}
private void RemoveEmptyParents(string? directory)
{
while (directory is not null && !directory.Equals(root, StringComparison.OrdinalIgnoreCase) &&
Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
{
Directory.Delete(directory);
directory = Path.GetDirectoryName(directory);
}
}
private string ContainedPath(string relative)
{
if (Path.IsPathRooted(relative))
throw new GameDataVaultValidationException("Game-data location must be app-relative.");
var path = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar)));
if (!path.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new GameDataVaultValidationException("Game-data location escapes the app-owned vault.");
return path;
}
private static string NormalizeRelative(string value) => value.Replace('\\', '/').TrimStart('/');
private static void RejectExecutableContent(ReadOnlySpan<byte> header)
{
if (header.StartsWith("MZ"u8) || header.StartsWith("#!"u8) ||
(header.Length >= 4 && header[..4].SequenceEqual(new byte[] { 0x7F, 0x45, 0x4C, 0x46 })) ||
IsMachO(header))
throw new GameDataVaultValidationException("Executable and script content is not accepted by the game-data vault.");
}
private static bool IsMachO(ReadOnlySpan<byte> header)
{
if (header.Length < 4) return false;
var magic = header[..4];
return magic.SequenceEqual(new byte[] { 0xFE, 0xED, 0xFA, 0xCE }) ||
magic.SequenceEqual(new byte[] { 0xFE, 0xED, 0xFA, 0xCF }) ||
magic.SequenceEqual(new byte[] { 0xCE, 0xFA, 0xED, 0xFE }) ||
magic.SequenceEqual(new byte[] { 0xCF, 0xFA, 0xED, 0xFE }) ||
magic.SequenceEqual(new byte[] { 0xCA, 0xFE, 0xBA, 0xBE });
}
private static async Task<(long Length, string Sha256)> HashExistingAsync(string path, CancellationToken ct)
{
for (var attempt = 0; ; attempt++)
{
try
{
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
var hash = await SHA256.HashDataAsync(stream, ct);
return (stream.Length, Convert.ToHexString(hash).ToLowerInvariant());
}
catch (IOException) when (attempt < 4)
{
// Windows can retain the atomic move's exclusive handle for a few milliseconds.
// Retry only the immutable destination read; uploads and source libraries are untouched.
await Task.Delay(TimeSpan.FromMilliseconds(10 * (attempt + 1)), ct);
}
}
}
}
public sealed class GameDataVaultValidationException(string message) : Exception(message);
public sealed class GameDataIntegrityException(string message) : Exception(message);
@@ -0,0 +1,131 @@
using System.Security.Cryptography;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.Infrastructure;
public sealed record StoredGameMedia(Stream Content, string ContentType, long Length);
public sealed class GameMediaStore
{
public const long MaximumBytes = 32L * 1024 * 1024;
private readonly string root;
public GameMediaStore(string root)
{
this.root = Path.GetFullPath(root);
Directory.CreateDirectory(this.root);
}
public async Task<GameMedia> SaveAsync(Guid gameId, Guid mediaId, GameMediaKind kind, string title,
Stream input, long? declaredLength, CancellationToken cancellationToken)
{
if (declaredLength is > MaximumBytes)
throw new MediaValidationException("Media exceeds the 32 MiB upload limit.");
if (kind is not (GameMediaKind.Screenshot or GameMediaKind.Manual or GameMediaKind.Background or GameMediaKind.Logo))
throw new MediaValidationException("Local uploads are limited to screenshots, manuals, backgrounds and logos.");
var directory = ContainedPath(gameId.ToString("N"));
Directory.CreateDirectory(directory);
var temporary = ContainedPath($"{gameId:N}/{mediaId:N}.uploading");
try
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var header = new byte[12];
var headerLength = 0;
long length = 0;
await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write,
FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan))
{
var buffer = new byte[64 * 1024];
while (true)
{
var read = await input.ReadAsync(buffer, cancellationToken);
if (read == 0) break;
length += read;
if (length > MaximumBytes)
throw new MediaValidationException("Media exceeds the 32 MiB upload limit.");
var take = Math.Min(header.Length - headerLength, read);
if (take > 0)
{
buffer.AsSpan(0, take).CopyTo(header.AsSpan(headerLength));
headerLength += take;
}
hash.AppendData(buffer, 0, read);
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
}
await output.FlushAsync(cancellationToken);
}
if (length == 0) throw new MediaValidationException("Media is empty.");
var (contentType, extension) = Detect(header.AsSpan(0, headerLength));
ValidateKind(kind, contentType);
var relative = $"{gameId:N}/{mediaId:N}{extension}";
var destination = ContainedPath(relative);
File.Move(temporary, destination, false);
var now = DateTimeOffset.UtcNow;
return new(mediaId, gameId, kind, LibraryExperiencePolicy.CleanTitle(title, "Media title"),
"Upload", "Manual", null, relative.Replace('\\', '/'), contentType, length,
Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(), true, false, now);
}
finally
{
if (File.Exists(temporary)) File.Delete(temporary);
}
}
public Task<StoredGameMedia?> OpenAsync(GameMedia media, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (!media.AppOwned || string.IsNullOrWhiteSpace(media.Location) || string.IsNullOrWhiteSpace(media.ContentType))
return Task.FromResult<StoredGameMedia?>(null);
var path = ContainedPath(media.Location);
if (!File.Exists(path)) return Task.FromResult<StoredGameMedia?>(null);
var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024,
FileOptions.Asynchronous | FileOptions.SequentialScan);
return Task.FromResult<StoredGameMedia?>(new(stream, media.ContentType, stream.Length));
}
public bool Delete(GameMedia media)
{
if (!media.AppOwned || string.IsNullOrWhiteSpace(media.Location)) return false;
var path = ContainedPath(media.Location);
if (!File.Exists(path)) return false;
File.Delete(path);
var directory = Path.GetDirectoryName(path);
if (directory is not null && Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
Directory.Delete(directory);
return true;
}
private string ContainedPath(string relative)
{
if (Path.IsPathRooted(relative)) throw new MediaValidationException("Media location must be app-relative.");
var path = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar)));
if (!path.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new MediaValidationException("Media location escapes the app-owned media directory.");
return path;
}
private static (string ContentType, string Extension) Detect(ReadOnlySpan<byte> header)
{
if (header.Length >= 8 && header[..8].SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }))
return ("image/png", ".png");
if (header.Length >= 3 && header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF)
return ("image/jpeg", ".jpg");
if (header.Length >= 12 && header[..4].SequenceEqual("RIFF"u8) && header[8..12].SequenceEqual("WEBP"u8))
return ("image/webp", ".webp");
if (header.Length >= 5 && header[..5].SequenceEqual("%PDF-"u8))
return ("application/pdf", ".pdf");
throw new MediaValidationException("Use a valid JPEG, PNG, WebP or PDF file.");
}
private static void ValidateKind(GameMediaKind kind, string contentType)
{
if (kind == GameMediaKind.Manual && contentType != "application/pdf")
throw new MediaValidationException("Manual uploads must be PDF files.");
if (kind != GameMediaKind.Manual && !contentType.StartsWith("image/", StringComparison.Ordinal))
throw new MediaValidationException("This media role requires a JPEG, PNG or WebP image.");
}
}
public sealed class MediaValidationException(string message) : Exception(message);
@@ -0,0 +1,202 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Xml;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.Infrastructure;
public sealed record MetadataEnrichmentResult(int Games, int Matched, int ClaimsAdded, int Unmatched,
ProviderHubResult? ProviderHub = null, string? LaunchBoxError = null);
public sealed class GameMetadataEnricher(ILudariumStore store, GameArtworkStore artworkStore, IGameProviderHub providerHub)
{
private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(30) };
private static readonly Guid SnapshotId = Guid.Parse("9871a5d1-5c0c-4ad0-8e59-6c94339073cb");
private const long MaximumArchiveBytes = 128L * 1024 * 1024;
public async Task<MetadataEnrichmentResult> EnrichAsync(IReadOnlyList<Game> games, IReadOnlyList<Release> releases,
CancellationToken ct, Func<int, int, Task>? progress = null)
{
MetadataEnrichmentResult launchBox;
try
{
launchBox = await EnrichLaunchBoxAsync(games, releases,
progress is null ? null : (completed, total) => progress(completed, checked(total * 2)), ct);
}
catch (Exception exception) when (exception is not OperationCanceledException and not OutOfMemoryException)
{
launchBox = new(games.Count, 0, 0, games.Count, LaunchBoxError: exception.Message);
}
var providerResult = await providerHub.EnrichAsync(games, releases, ct,
progress is null ? null : (completed, total) => progress(checked(total + completed), checked(total * 2)));
return launchBox with { ProviderHub = providerResult };
}
private async Task<MetadataEnrichmentResult> EnrichLaunchBoxAsync(IReadOnlyList<Game> games, IReadOnlyList<Release> releases,
Func<int, int, Task>? progress, CancellationToken ct)
{
var directory = Path.Combine(Path.GetDirectoryName(artworkStore.DirectoryPath)!, "catalogs");
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, "launchbox-metadata.zip");
string? etag = null;
string? refreshError = null;
try
{
if (!File.Exists(path) || new FileInfo(path).Length is < 1_000_000 or > MaximumArchiveBytes || File.GetLastWriteTimeUtc(path) < DateTime.UtcNow.AddDays(-7))
{
try { etag = await DownloadAsync(path, ct); }
catch (Exception exception) when (exception is not OperationCanceledException && IsUsableSnapshot(path))
{
// A failed refresh must not discard the last-known-good, app-owned snapshot.
refreshError = exception.Message;
}
}
var hash = await HashAsync(path, ct);
var matched = await ImportAsync(path, games, releases, progress, ct);
var now = DateTimeOffset.UtcNow;
await store.UpsertProviderSnapshotAsync(new(SnapshotId, "LaunchBox", hash[..12],
refreshError is null ? "Ready" : "Degraded", etag, hash,
now, refreshError is null ? now.AddDays(7) : now, now, refreshError), ct);
return matched;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
var existing = (await store.ListProviderSnapshotsAsync(ct)).FirstOrDefault(item => item.Provider == "LaunchBox");
var now = DateTimeOffset.UtcNow;
await store.UpsertProviderSnapshotAsync(existing is null
? new(SnapshotId, "LaunchBox", "unavailable", "Offline", etag, new string('0', 64), now, now, null, exception.Message)
: existing with { State = "Degraded", Error = exception.Message, ExpiresAt = now }, ct);
throw;
}
}
private static bool IsUsableSnapshot(string path)
{
try
{
var info = new FileInfo(path);
return info.Exists && info.Length is >= 1_000_000 and <= MaximumArchiveBytes;
}
catch (IOException) { return false; }
catch (UnauthorizedAccessException) { return false; }
}
private async Task<MetadataEnrichmentResult> ImportAsync(string path, IReadOnlyList<Game> games,
IReadOnlyList<Release> releases, Func<int, int, Task>? progress, CancellationToken ct)
{
var platforms = releases.GroupBy(item => item.GameId).ToDictionary(group => group.Key,
group => group.Select(item => LaunchBoxPlatform(item.Platform)).Where(item => item is not null).ToHashSet(StringComparer.OrdinalIgnoreCase));
var wanted = games.GroupBy(game => GameArtworkEnricher.Normalize(game.Title)).ToDictionary(group => group.Key, group => group.ToArray());
var matchedIds = new HashSet<Guid>();
var claims = 0;
using var archive = ZipFile.OpenRead(path);
var entry = archive.GetEntry("Metadata.xml") ?? throw new InvalidDataException("LaunchBox metadata entry is missing.");
if (entry.Length > 650L * 1024 * 1024 || entry.CompressedLength == 0 || entry.Length / entry.CompressedLength > 20)
throw new InvalidDataException("LaunchBox metadata exceeds its decompression safety limits.");
await using var input = entry.Open();
using var reader = XmlReader.Create(input, new XmlReaderSettings { Async = true, DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null });
while (await reader.ReadAsync())
{
ct.ThrowIfCancellationRequested();
if (reader.NodeType != XmlNodeType.Element || reader.Name != "Game") continue;
using var subtree = reader.ReadSubtree();
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
while (subtree.Read())
{
if (subtree.NodeType != XmlNodeType.Element || !SupportedElement(subtree.Name)) continue;
values[subtree.Name] = subtree.ReadElementContentAsString();
}
if (!values.TryGetValue("Name", out var name) || !values.TryGetValue("Platform", out var platform) ||
!wanted.TryGetValue(GameArtworkEnricher.Normalize(name), out var candidates)) continue;
var game = candidates.FirstOrDefault(candidate => platforms.GetValueOrDefault(candidate.Id)?.Contains(platform) == true);
if (game is null || !matchedIds.Add(game.Id)) continue;
foreach (var mapping in FieldMappings)
{
if (!values.TryGetValue(mapping.Key, out var value) || string.IsNullOrWhiteSpace(value)) continue;
var externalId = values.GetValueOrDefault("DatabaseID") ?? name;
var claimId = DeterministicGuid($"launchbox|{externalId}|{game.Id:N}|{mapping.Value}");
await store.AddClaimAsync(new(claimId, "Game", game.Id, mapping.Value, value.Trim(), "ProviderSnapshot",
$"LaunchBox:{externalId}", Confidence.High,
[new Evidence("metadata.provider", mapping.Key, "LaunchBox", "1", Confidence.High, DateTimeOffset.UtcNow)],
false, DateTimeOffset.UtcNow), ct);
claims++;
}
if (progress is not null) await progress(matchedIds.Count, games.Count);
}
if (progress is not null) await progress(games.Count, games.Count);
return new(games.Count, matchedIds.Count, claims, games.Count - matchedIds.Count);
}
private static readonly Dictionary<string, string> FieldMappings = new()
{
["Name"] = "officialTitle",
["AlternateNames"] = "alternateTitles",
["Overview"] = "description",
["ReleaseDate"] = "releaseDate",
["Developer"] = "developer",
["Publisher"] = "publisher",
["Genres"] = "genre",
["MaxPlayers"] = "players",
["Region"] = "region",
["Series"] = "franchise",
["Version"] = "edition"
};
private static bool SupportedElement(string name) => name == "DatabaseID" || name == "Platform" || FieldMappings.ContainsKey(name);
private static string? LaunchBoxPlatform(string? platform) => platform switch
{
"windows" => "Windows",
"ps5" => "Sony Playstation 5",
"ps4" => "Sony Playstation 4",
"psvita" => "Sony Playstation Vita",
"psp" => "Sony PSP",
"ps2" => "Sony Playstation 2",
"psx" => "Sony Playstation",
"switch" => "Nintendo Switch",
"wii" => "Nintendo Wii",
"3ds" => "Nintendo 3DS",
"nds" => "Nintendo DS",
"n64" => "Nintendo 64",
"gba" => "Nintendo Game Boy Advance",
"gbc" => "Nintendo Game Boy Color",
"gb" => "Nintendo Game Boy",
"snes" => "Super Nintendo Entertainment System",
"nes" => "Nintendo Entertainment System",
_ => null
};
private static async Task<string?> DownloadAsync(string destination, CancellationToken ct)
{
var temporary = destination + ".metadata-" + Guid.NewGuid().ToString("N");
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, "https://gamesdb.launchbox-app.com/Metadata.zip");
request.Headers.UserAgent.ParseAdd(ReleaseIdentity.UserAgent);
using var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength > MaximumArchiveBytes) throw new InvalidDataException("Metadata catalog exceeds 128 MiB.");
await using var input = await response.Content.ReadAsStreamAsync(ct);
await using var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, FileOptions.Asynchronous);
var buffer = new byte[64 * 1024];
while (true)
{
var read = await input.ReadAsync(buffer, ct); if (read == 0) break;
if (output.Length + read > MaximumArchiveBytes) throw new InvalidDataException("Metadata catalog exceeds 128 MiB.");
await output.WriteAsync(buffer.AsMemory(0, read), ct);
}
await output.FlushAsync(ct); File.Move(temporary, destination, true);
return response.Headers.ETag?.Tag;
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
private static async Task<string> HashAsync(string path, CancellationToken ct)
{
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
return Convert.ToHexString(await SHA256.HashDataAsync(stream, ct)).ToLowerInvariant();
}
private static Guid DeterministicGuid(string value) => new(SHA256.HashData(Encoding.UTF8.GetBytes(value))[..16]);
}
@@ -0,0 +1,226 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.Infrastructure;
public sealed class GameProviderHub(
IEnumerable<IGameMetadataProvider> providers,
ILudariumStore store,
ILibraryExperienceStore experienceStore,
GameArtworkStore artworkStore) : IGameProviderHub
{
private static readonly HttpClient ArtworkClient = CreateArtworkClient();
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IReadOnlyList<IGameMetadataProvider> providers = providers.ToArray();
public IReadOnlyList<ProviderDescriptor> DescribeProviders() => providers.Select(item => item.Describe()).ToArray();
public async Task<ProviderValidationResult> ValidateAsync(string providerId, CancellationToken cancellationToken)
{
var provider = providers.SingleOrDefault(item => item.Describe().Id.Equals(providerId, StringComparison.OrdinalIgnoreCase))
?? throw new KeyNotFoundException("Metadata provider not found.");
var descriptor = provider.Describe();
var now = DateTimeOffset.UtcNow;
if (!descriptor.Configured)
return new(descriptor.Id, descriptor.Name, "NotConfigured", false, false, false,
descriptor.Message ?? "Provider credentials are not configured.", now);
try
{
var game = new Game(DeterministicGuid("provider-validation|sonic-the-hedgehog"),
"Sonic the Hedgehog", DateTimeOffset.UnixEpoch, Origin: GameOrigin.Manual);
var release = new Release(DeterministicGuid("provider-validation|sonic-the-hedgehog|genesis"),
game.Id, game.Title, "genesis", "world", null, DateTimeOffset.UnixEpoch, Origin: ReleaseOrigin.Manual);
var result = await provider.EnrichAsync(game, release, cancellationToken);
var matched = result is not null;
var message = matched
? "Credentials, endpoint and deterministic validation lookup succeeded."
: "Credentials and endpoint succeeded, but the deterministic validation title was not returned.";
await SaveValidationSnapshotAsync(descriptor, matched ? "Ready" : "Degraded", message,
matched ? now : null, now, cancellationToken);
return new(descriptor.Id, descriptor.Name, matched ? "Ready" : "Degraded", true, true, matched,
message, now);
}
catch (Exception exception) when (!cancellationToken.IsCancellationRequested && exception is not OutOfMemoryException)
{
var message = $"Live validation failed: {ProviderFailureMessage(exception)}";
await SaveValidationSnapshotAsync(descriptor, "Offline", message, null, now, cancellationToken);
return new(descriptor.Id, descriptor.Name, "Offline", true, false, false, message, now);
}
}
public async Task<ProviderHubResult> EnrichAsync(IReadOnlyList<Game> games, IReadOnlyList<Release> releases,
CancellationToken cancellationToken, Func<int, int, Task>? progress = null)
{
var configured = providers.Where(item => item.Describe().Configured).ToArray();
var byGame = releases.GroupBy(item => item.GameId).ToDictionary(group => group.Key, group => group.First());
var matched = 0;
var claimsAdded = 0;
var artworkCandidates = 0;
var achievementSnapshots = 0;
var reviewItems = 0;
var failed = 0;
var providerSuccess = configured.ToDictionary(item => item.Describe().Id, _ => 0, StringComparer.Ordinal);
var providerFailure = configured.ToDictionary(item => item.Describe().Id, _ => 0, StringComparer.Ordinal);
for (var index = 0; index < games.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var game = games[index];
var gameMatched = false;
foreach (var provider in configured)
{
var descriptor = provider.Describe();
try
{
var result = await provider.EnrichAsync(game, byGame.GetValueOrDefault(game.Id), cancellationToken);
providerSuccess[descriptor.Id]++;
if (result is null) continue;
gameMatched = true;
var existing = await store.ListClaimsAsync("Game", game.Id, cancellationToken);
foreach (var field in result.Fields)
{
var value = field.Value.Trim();
if (value.Length == 0) continue;
var conflicting = existing.Where(item => item.Field.Equals(field.Field, StringComparison.OrdinalIgnoreCase)
&& !item.Value.Equals(value, StringComparison.OrdinalIgnoreCase)).ToArray();
var claim = new MetadataClaim(
DeterministicGuid($"claim|{field.Provider}|{field.ExternalId}|{game.Id:N}|{field.Field}|{value}"),
"Game", game.Id, field.Field, value, "ProviderApi", $"{field.Provider}:{field.ExternalId}",
field.Confidence,
[new Evidence("metadata.provider-api", field.Field, field.Provider, field.SourceVersion,
field.Confidence, DateTimeOffset.UtcNow)], false, DateTimeOffset.UtcNow);
await store.AddClaimAsync(claim, cancellationToken);
claimsAdded++;
existing = existing.Append(claim).ToArray();
if (conflicting.Any(item => item.ManualLock || item.Confidence >= Confidence.High))
{
await AddConflictReviewAsync(game, field, conflicting, cancellationToken);
reviewItems++;
}
}
foreach (var candidate in result.Artwork.Take(2))
{
if (!AllowedArtworkUri(candidate, out var uri)) continue;
try
{
using var response = await ArtworkClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength is > GameArtworkStore.MaximumBytes) continue;
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
await artworkStore.SaveAsync(game.Id, input, response.Content.Headers.ContentLength,
cancellationToken, candidate.Provider);
var artwork = await artworkStore.DescribeAsync(game.Id, candidate.Provider,
"provider-exact-title-box-front", candidate.Confidence, cancellationToken,
result.Platform);
if (artwork is null) continue;
artwork = artwork with { ExternalId = candidate.ExternalId };
await store.UpsertGameArtworkAsync(artwork, cancellationToken);
artworkCandidates++;
}
catch (Exception exception) when (exception is not OperationCanceledException and not OutOfMemoryException)
{
providerFailure[descriptor.Id]++;
}
}
if (result.AchievementProgress is { } achievements)
{
await experienceStore.UpsertAchievementProgressAsync(achievements with { GameId = game.Id }, cancellationToken);
achievementSnapshots++;
}
}
catch (Exception exception) when (exception is not OperationCanceledException and not OutOfMemoryException)
{
providerFailure[descriptor.Id]++;
failed++;
}
}
if (gameMatched) matched++;
if (progress is not null) await progress(index + 1, games.Count);
}
foreach (var provider in configured)
{
var descriptor = provider.Describe();
var now = DateTimeOffset.UtcNow;
var failures = providerFailure[descriptor.Id];
await store.UpsertProviderSnapshotAsync(new(
DeterministicGuid($"provider|{descriptor.Id}"), descriptor.Name, "api-v1",
failures == 0 ? "Ready" : "Degraded", null, new string('0', 64), now,
now.AddHours(12), providerSuccess[descriptor.Id] > 0 ? now : null,
failures == 0 ? null : $"{failures} request(s) failed; successful matches were preserved."), cancellationToken);
}
return new(games.Count, configured.Length, matched, claimsAdded, artworkCandidates,
achievementSnapshots, reviewItems, failed);
}
private async Task AddConflictReviewAsync(Game game, MetadataFieldCandidate field,
IReadOnlyList<MetadataClaim> conflicting, CancellationToken cancellationToken)
{
var payload = JsonSerializer.Serialize(new
{
gameId = game.Id,
game.Title,
field = field.Field,
candidate = field.Value,
provider = field.Provider,
existing = conflicting.Select(item => new { item.Value, item.SourceId, item.Confidence, item.ManualLock })
}, JsonOptions);
var id = DeterministicGuid($"provider-conflict|{game.Id:N}|{field.Field}|{field.Provider}|{field.ExternalId}");
await store.AddReviewAsync(new(id, "Provider metadata conflicts with an existing high-confidence or manual value.",
Severity.Warning, ReviewState.Open, payload, DateTimeOffset.UtcNow), cancellationToken);
}
private Task SaveValidationSnapshotAsync(ProviderDescriptor descriptor, string state, string message,
DateTimeOffset? successfulAt, DateTimeOffset now, CancellationToken cancellationToken) =>
store.UpsertProviderSnapshotAsync(new(
DeterministicGuid($"provider|{descriptor.Id}"), descriptor.Name, "api-v1", state, null,
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{descriptor.Id}|{state}|{now:O}"))).ToLowerInvariant(),
now, now.AddHours(12), successfulAt, message), cancellationToken);
private static string ProviderFailureMessage(Exception exception) => exception switch
{
HttpRequestException { StatusCode: { } status } => $"provider returned HTTP {(int)status}",
HttpRequestException => "provider endpoint could not be reached",
TaskCanceledException => "provider request timed out",
InvalidDataException => "provider returned an invalid response",
JsonException => "provider returned invalid JSON",
_ => "provider request could not be completed"
};
private static bool AllowedArtworkUri(ProviderArtworkCandidate candidate, out Uri uri)
{
if (!Uri.TryCreate(candidate.Url, UriKind.Absolute, out uri!) || uri.Scheme != Uri.UriSchemeHttps) return false;
var allowed = candidate.Provider.ToLowerInvariant() switch
{
"igdb" => uri.Host.Equals("images.igdb.com", StringComparison.OrdinalIgnoreCase),
"mobygames" => IsHostOrSubdomain(uri.Host, "mobygames.com"),
"screenscraper" => IsHostOrSubdomain(uri.Host, "screenscraper.fr"),
"retroachievements" => IsHostOrSubdomain(uri.Host, "retroachievements.org"),
_ => false
};
return allowed;
}
private static bool IsHostOrSubdomain(string host, string expected) =>
host.Equals(expected, StringComparison.OrdinalIgnoreCase) ||
host.EndsWith('.' + expected, StringComparison.OrdinalIgnoreCase);
private static HttpClient CreateArtworkClient()
{
var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{
Timeout = TimeSpan.FromSeconds(20)
};
client.DefaultRequestHeaders.UserAgent.ParseAdd(ReleaseIdentity.UserAgent);
return client;
}
internal static Guid DeterministicGuid(string value) => new(SHA256.HashData(Encoding.UTF8.GetBytes(value))[..16]);
}
@@ -0,0 +1,507 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Xml;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.Infrastructure;
public sealed class IgdbMetadataProvider(HttpClient client, string? clientId, string? clientSecret) : IGameMetadataProvider
{
private static readonly SemaphoreSlim TokenLock = new(1, 1);
private string? accessToken;
private DateTimeOffset tokenExpiresAt;
public ProviderDescriptor Describe()
{
var configured = !string.IsNullOrWhiteSpace(clientId) && !string.IsNullOrWhiteSpace(clientSecret);
return new("igdb", "IGDB", configured, configured ? "Ready" : "Not configured",
["metadata", "artwork"], true, "https://api-docs.igdb.com/",
configured ? null : "Set IGDB_CLIENT_ID and IGDB_CLIENT_SECRET to enable this provider.");
}
public async Task<ProviderGameResult?> EnrichAsync(Game game, Release? release, CancellationToken cancellationToken)
{
if (!Describe().Configured) return null;
var token = await GetTokenAsync(cancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.igdb.com/v4/games");
request.Headers.Add("Client-ID", clientId);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var title = game.Title.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal);
request.Content = new StringContent($"search \"{title}\"; fields id,name,summary,first_release_date,genres.name,involved_companies.company.name,involved_companies.developer,involved_companies.publisher,franchises.name,cover.image_id,platforms.slug,platforms.name; limit 8;", Encoding.UTF8, "text/plain");
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await ProviderHttp.ReadTextAsync(response.Content, 2 * 1024 * 1024, cancellationToken);
return Parse(json, game, release);
}
internal static ProviderGameResult? Parse(string json, Game game, Release? release)
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Array) return null;
var exact = document.RootElement.EnumerateArray()
.Where(item => ProviderText.Normalize(ProviderJson.String(item, "name")) == ProviderText.Normalize(game.Title))
.ToArray();
if (exact.Length == 0) return null;
var platform = ProviderText.NormalizePlatform(release?.Platform);
var matches = platform is null ? exact : exact.Where(item => ProviderJson.Array(item, "platforms")
.Any(value => ProviderText.PlatformMatches(platform, ProviderJson.String(value, "slug"), ProviderJson.String(value, "name")))).ToArray();
var selected = matches.Length == 1 ? matches[0] : exact.Length == 1 ? exact[0] : default;
if (selected.ValueKind == JsonValueKind.Undefined) return null;
var externalId = ProviderJson.Int(selected, "id")?.ToString(CultureInfo.InvariantCulture) ?? game.Title;
var fields = new List<MetadataFieldCandidate>();
Add(fields, "officialTitle", ProviderJson.String(selected, "name"), externalId);
Add(fields, "description", ProviderJson.String(selected, "summary"), externalId);
if (ProviderJson.Long(selected, "first_release_date") is { } timestamp)
Add(fields, "releaseDate", DateTimeOffset.FromUnixTimeSeconds(timestamp).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), externalId);
Add(fields, "genre", JoinNames(selected, "genres"), externalId);
Add(fields, "franchise", JoinNames(selected, "franchises"), externalId);
var companies = ProviderJson.Array(selected, "involved_companies");
Add(fields, "developer", JoinCompanies(companies, "developer"), externalId);
Add(fields, "publisher", JoinCompanies(companies, "publisher"), externalId);
var artwork = new List<ProviderArtworkCandidate>();
if (ProviderJson.Object(selected, "cover") is { } cover && ProviderJson.String(cover, "image_id") is { Length: > 0 } imageId)
artwork.Add(new($"https://images.igdb.com/igdb/image/upload/t_cover_big_2x/{Uri.EscapeDataString(imageId)}.jpg",
"box-front", "IGDB", externalId, Confidence.High));
return new("IGDB", externalId, ProviderJson.String(selected, "name") ?? game.Title,
release?.Platform, fields, artwork);
}
private async Task<string> GetTokenAsync(CancellationToken cancellationToken)
{
if (accessToken is not null && tokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(2)) return accessToken;
await TokenLock.WaitAsync(cancellationToken);
try
{
if (accessToken is not null && tokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(2)) return accessToken;
var uri = $"https://id.twitch.tv/oauth2/token?client_id={Uri.EscapeDataString(clientId!)}&client_secret={Uri.EscapeDataString(clientSecret!)}&grant_type=client_credentials";
using var response = await client.PostAsync(uri, new ByteArrayContent([]), cancellationToken);
response.EnsureSuccessStatusCode();
var json = await ProviderHttp.ReadTextAsync(response.Content, 64 * 1024, cancellationToken);
using var document = JsonDocument.Parse(json);
accessToken = ProviderJson.String(document.RootElement, "access_token")
?? throw new InvalidDataException("IGDB token response did not contain an access token.");
var lifetime = ProviderJson.Int(document.RootElement, "expires_in") ?? 3600;
tokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, lifetime));
return accessToken;
}
finally { TokenLock.Release(); }
}
private static string? JoinNames(JsonElement item, string property) => Join(ProviderJson.Array(item, property)
.Select(value => ProviderJson.String(value, "name")));
private static string? JoinCompanies(IEnumerable<JsonElement> items, string role) => Join(items
.Where(item => ProviderJson.Bool(item, role) == true)
.Select(item => ProviderJson.Object(item, "company") is { } company ? ProviderJson.String(company, "name") : null));
private static string? Join(IEnumerable<string?> values)
{
var result = string.Join(", ", values.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase));
return result.Length == 0 ? null : result;
}
private static void Add(List<MetadataFieldCandidate> fields, string field, string? value, string externalId)
{
if (!string.IsNullOrWhiteSpace(value)) fields.Add(new(field, value.Trim(), "IGDB", externalId, Confidence.High, "v4"));
}
}
public sealed class MobyGamesMetadataProvider(HttpClient client, string? apiKey) : IGameMetadataProvider
{
public ProviderDescriptor Describe()
{
var configured = !string.IsNullOrWhiteSpace(apiKey);
return new("mobygames", "MobyGames", configured, configured ? "Ready" : "Not configured",
["metadata", "artwork"], true, "https://www.mobygames.com/info/api/",
configured ? null : "Set MOBYGAMES_API_KEY to enable this provider.");
}
public async Task<ProviderGameResult?> EnrichAsync(Game game, Release? release, CancellationToken cancellationToken)
{
if (!Describe().Configured) return null;
var uri = $"https://api.mobygames.com/v1/games?api_key={Uri.EscapeDataString(apiKey!)}&title={Uri.EscapeDataString(game.Title)}&format=normal&limit=10";
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await ProviderHttp.ReadTextAsync(response.Content, 4 * 1024 * 1024, cancellationToken);
return Parse(json, game, release);
}
internal static ProviderGameResult? Parse(string json, Game game, Release? release)
{
using var document = JsonDocument.Parse(json);
var games = ProviderJson.Array(document.RootElement, "games")
.Where(item => ProviderText.Normalize(ProviderJson.String(item, "title")) == ProviderText.Normalize(game.Title)).ToArray();
if (games.Length == 0) return null;
var platform = ProviderText.NormalizePlatform(release?.Platform);
var matches = platform is null ? games : games.Where(item => ProviderJson.Array(item, "platforms")
.Any(value => ProviderText.PlatformMatches(platform, ProviderJson.String(value, "platform_name"), ProviderJson.String(value, "platform")))).ToArray();
var selected = matches.Length == 1 ? matches[0] : games.Length == 1 ? games[0] : default;
if (selected.ValueKind == JsonValueKind.Undefined) return null;
var externalId = ProviderJson.Int(selected, "game_id")?.ToString(CultureInfo.InvariantCulture) ?? game.Title;
var fields = new List<MetadataFieldCandidate>();
Add(fields, "officialTitle", ProviderJson.String(selected, "title"), externalId);
Add(fields, "description", ProviderJson.String(selected, "description"), externalId);
var genres = string.Join(", ", ProviderJson.Array(selected, "genres").Select(item =>
ProviderJson.String(item, "genre_name") ?? ProviderJson.String(item, "name")).Where(value => !string.IsNullOrWhiteSpace(value)));
Add(fields, "genre", genres, externalId);
var artwork = new List<ProviderArtworkCandidate>();
var cover = ProviderJson.String(selected, "sample_cover") ?? ProviderJson.String(selected, "cover_url");
if (Uri.TryCreate(cover, UriKind.Absolute, out var coverUri) && coverUri.Scheme == Uri.UriSchemeHttps)
artwork.Add(new(cover, "box-front", "MobyGames", externalId, Confidence.High));
return new("MobyGames", externalId, ProviderJson.String(selected, "title") ?? game.Title,
release?.Platform, fields, artwork);
}
private static void Add(List<MetadataFieldCandidate> fields, string field, string? value, string externalId)
{
if (!string.IsNullOrWhiteSpace(value)) fields.Add(new(field, value.Trim(), "MobyGames", externalId, Confidence.High, "v1"));
}
}
public sealed class ScreenScraperMetadataProvider(
HttpClient client,
string? developerId,
string? developerPassword,
string? softwareName,
string? userName,
string? userPassword) : IGameMetadataProvider
{
private static readonly Dictionary<string, int> SystemIds = new(StringComparer.OrdinalIgnoreCase)
{
["genesis"] = 1,
["master-system"] = 2,
["nes"] = 3,
["snes"] = 4,
["gba"] = 12,
["gbc"] = 10,
["gb"] = 9,
["n64"] = 14,
["gamecube"] = 13,
["nds"] = 15,
["3ds"] = 17,
["dreamcast"] = 23,
["saturn"] = 22,
["game-gear"] = 21,
["psx"] = 57,
["ps2"] = 58,
["psp"] = 61,
["atari-2600"] = 26,
["arcade"] = 75
};
public ProviderDescriptor Describe()
{
var configured = !string.IsNullOrWhiteSpace(developerId) && !string.IsNullOrWhiteSpace(developerPassword)
&& !string.IsNullOrWhiteSpace(softwareName);
return new("screenscraper", "ScreenScraper", configured, configured ? "Ready" : "Not configured",
["metadata", "artwork"], true, "https://www.screenscraper.fr/webapi2.php",
configured ? null : "Set SCREENSCRAPER_DEVID, SCREENSCRAPER_DEVPASSWORD and SCREENSCRAPER_SOFTNAME.");
}
public async Task<ProviderGameResult?> EnrichAsync(Game game, Release? release, CancellationToken cancellationToken)
{
if (!Describe().Configured || !SystemIds.TryGetValue(ProviderText.NormalizePlatform(release?.Platform) ?? "", out var systemId)) return null;
var query = new Dictionary<string, string?>
{
["devid"] = developerId,
["devpassword"] = developerPassword,
["softname"] = softwareName,
["ssid"] = userName,
["sspassword"] = userPassword,
["output"] = "xml",
["romnom"] = game.Title,
["systemeid"] = systemId.ToString(CultureInfo.InvariantCulture)
};
var uri = "https://www.screenscraper.fr/api2/jeuInfos.php?" + string.Join("&", query
.Where(item => !string.IsNullOrWhiteSpace(item.Value))
.Select(item => $"{Uri.EscapeDataString(item.Key)}={Uri.EscapeDataString(item.Value!)}"));
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var xml = await ProviderHttp.ReadTextAsync(response.Content, 4 * 1024 * 1024, cancellationToken);
return Parse(xml, game, release);
}
internal static ProviderGameResult? Parse(string xml, Game game, Release? release)
{
var document = new XmlDocument { XmlResolver = null };
using var textReader = new StringReader(xml);
using var reader = XmlReader.Create(textReader, new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null
});
document.Load(reader);
var node = document.SelectSingleNode("//jeu");
if (node is null) return null;
var names = node.SelectNodes("noms/nom")?.Cast<XmlNode>().Select(item => item.InnerText.Trim()).Where(item => item.Length > 0).ToArray() ?? [];
var title = names.FirstOrDefault(item => ProviderText.Normalize(item) == ProviderText.Normalize(game.Title));
if (title is null) return null;
var externalId = node.Attributes?["id"]?.Value ?? title;
var fields = new List<MetadataFieldCandidate>();
Add(fields, "officialTitle", title, externalId);
Add(fields, "alternateTitles", string.Join(", ", names.Where(item => !item.Equals(title, StringComparison.OrdinalIgnoreCase))), externalId);
Add(fields, "description", PreferredText(node, "synopsis/synopsis"), externalId);
Add(fields, "genre", PreferredText(node, "genres/genre/noms/nom"), externalId);
Add(fields, "developer", node.SelectSingleNode("developpeur")?.InnerText, externalId);
Add(fields, "publisher", node.SelectSingleNode("editeur")?.InnerText, externalId);
Add(fields, "players", node.SelectSingleNode("joueurs")?.InnerText, externalId);
Add(fields, "releaseDate", PreferredText(node, "dates/date"), externalId);
var artwork = new List<ProviderArtworkCandidate>();
var mediaNodes = node.SelectNodes("medias/media");
if (mediaNodes is not null)
{
foreach (XmlNode media in mediaNodes)
{
var type = media.Attributes?["type"]?.Value;
var url = media.InnerText.Trim();
if (type is "box-2D" or "box-3D" && Uri.TryCreate(url, UriKind.Absolute, out var parsed) && parsed.Scheme == Uri.UriSchemeHttps)
{
artwork.Add(new(url, "box-front", "ScreenScraper", externalId, Confidence.High));
break;
}
}
}
return new("ScreenScraper", externalId, title, release?.Platform, fields, artwork);
}
private static string? PreferredText(XmlNode node, string path)
{
var values = node.SelectNodes(path)?.Cast<XmlNode>().ToArray() ?? [];
return values.FirstOrDefault(item => item.Attributes?["langue"]?.Value is "en" or "fr")?.InnerText
?? values.FirstOrDefault()?.InnerText;
}
private static void Add(List<MetadataFieldCandidate> fields, string field, string? value, string externalId)
{
if (!string.IsNullOrWhiteSpace(value)) fields.Add(new(field, value.Trim(), "ScreenScraper", externalId, Confidence.High, "api2"));
}
}
public sealed class RetroAchievementsMetadataProvider(HttpClient client, string? apiKey, string? userName) : IGameMetadataProvider
{
private readonly ConcurrentDictionary<int, Task<IReadOnlyList<RetroGame>>> gameLists = new();
private static readonly Dictionary<string, int> SystemIds = new(StringComparer.OrdinalIgnoreCase)
{
["genesis"] = 1,
["n64"] = 2,
["snes"] = 3,
["gb"] = 4,
["gba"] = 5,
["gbc"] = 6,
["nes"] = 7,
["pc-engine"] = 8,
["sega-cd"] = 9,
["sega-32x"] = 10,
["master-system"] = 11,
["psx"] = 12,
["atari-lynx"] = 13,
["neo-geo-pocket"] = 14,
["game-gear"] = 15,
["gamecube"] = 16,
["atari-jaguar"] = 17,
["nds"] = 18,
["wii"] = 19,
["wii-u"] = 20,
["ps2"] = 21,
["xbox"] = 22,
["odyssey2"] = 23,
["pokemon-mini"] = 24,
["atari-2600"] = 25,
["dos"] = 26,
["arcade"] = 27,
["virtual-boy"] = 28,
["c64"] = 30,
["amiga"] = 35,
["atari-st"] = 36,
["saturn"] = 39,
["dreamcast"] = 40,
["psp"] = 41,
["3do"] = 43,
["colecovision"] = 44,
["intellivision"] = 45,
["vectrex"] = 46,
["pc-98"] = 48,
["pc-fx"] = 49,
["atari-5200"] = 50,
["atari-7800"] = 51,
["wonderswan"] = 53,
["neo-geo-cd"] = 56,
["zx-spectrum"] = 59
};
public ProviderDescriptor Describe()
{
var configured = !string.IsNullOrWhiteSpace(apiKey);
return new("retroachievements", "RetroAchievements", configured, configured ? "Ready" : "Not configured",
["metadata", "artwork", "achievements"], true, "https://api-docs.retroachievements.org/",
configured && string.IsNullOrWhiteSpace(userName)
? "Achievement definitions are enabled. Set RETROACHIEVEMENTS_USERNAME for personal unlock progress."
: configured ? null : "Set RETROACHIEVEMENTS_API_KEY to enable this provider.");
}
public async Task<ProviderGameResult?> EnrichAsync(Game game, Release? release, CancellationToken cancellationToken)
{
if (!Describe().Configured || !SystemIds.TryGetValue(ProviderText.NormalizePlatform(release?.Platform) ?? "", out var systemId)) return null;
var games = await gameLists.GetOrAdd(systemId, id => LoadGamesAsync(id, CancellationToken.None));
cancellationToken.ThrowIfCancellationRequested();
var matches = games.Where(item => ProviderText.Normalize(item.Title) == ProviderText.Normalize(game.Title)).ToArray();
if (matches.Length != 1) return null;
var match = matches[0];
var endpoint = string.IsNullOrWhiteSpace(userName)
? $"API_GetGameExtended.php?i={match.Id}"
: $"API_GetGameInfoAndUserProgress.php?g={match.Id}&u={Uri.EscapeDataString(userName)}";
using var response = await client.GetAsync($"https://retroachievements.org/API/{endpoint}&y={Uri.EscapeDataString(apiKey!)}",
HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await ProviderHttp.ReadTextAsync(response.Content, 8 * 1024 * 1024, cancellationToken);
return ParseDetails(json, game, release, match.Id);
}
internal static ProviderGameResult? ParseDetails(string json, Game game, Release? release, int expectedId)
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
var id = ProviderJson.Int(root, "id") ?? ProviderJson.Int(root, "ID");
if (id != expectedId) return null;
var externalId = expectedId.ToString(CultureInfo.InvariantCulture);
var title = ProviderJson.String(root, "title") ?? ProviderJson.String(root, "Title") ?? game.Title;
if (ProviderText.Normalize(title) != ProviderText.Normalize(game.Title)) return null;
var fields = new List<MetadataFieldCandidate>();
Add(fields, "officialTitle", title, externalId);
Add(fields, "developer", ProviderJson.String(root, "developer"), externalId);
Add(fields, "publisher", ProviderJson.String(root, "publisher"), externalId);
Add(fields, "genre", ProviderJson.String(root, "genre"), externalId);
Add(fields, "releaseDate", ProviderJson.String(root, "released"), externalId);
var artwork = new List<ProviderArtworkCandidate>();
var box = ProviderJson.String(root, "imageBoxArt");
if (box is { Length: > 0 }) artwork.Add(new(new Uri(new Uri("https://retroachievements.org"), box).AbsoluteUri,
"box-front", "RetroAchievements", externalId, Confidence.High));
var achievements = ParseAchievements(root, game.Id, externalId);
return new("RetroAchievements", externalId, title, release?.Platform, fields, artwork, achievements);
}
private async Task<IReadOnlyList<RetroGame>> LoadGamesAsync(int systemId, CancellationToken cancellationToken)
{
var result = new List<RetroGame>();
const int pageSize = 1000;
for (var offset = 0; ; offset += pageSize)
{
var uri = $"https://retroachievements.org/API/API_GetGameList.php?i={systemId}&f=1&h=0&o={offset}&c={pageSize}&y={Uri.EscapeDataString(apiKey!)}";
using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await ProviderHttp.ReadTextAsync(response.Content, 8 * 1024 * 1024, cancellationToken);
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Array) break;
var page = document.RootElement.EnumerateArray().Select(item => new RetroGame(
ProviderJson.Int(item, "id") ?? 0, ProviderJson.String(item, "title") ?? ""))
.Where(item => item.Id > 0 && item.Title.Length > 0).ToArray();
result.AddRange(page);
if (page.Length < pageSize) break;
}
return result;
}
private static GameAchievementProgress ParseAchievements(JsonElement root, Guid gameId, string externalId)
{
var items = new List<Achievement>();
if (ProviderJson.Object(root, "achievements") is { } achievements)
{
foreach (var property in achievements.EnumerateObject())
{
var item = property.Value;
var id = ProviderJson.Int(item, "id") ?? (int.TryParse(property.Name, out var parsed) ? parsed : 0);
if (id <= 0) continue;
var unlockedAtText = ProviderJson.String(item, "dateEarnedHardcore") ?? ProviderJson.String(item, "dateEarned");
var unlockedAt = DateTimeOffset.TryParse(unlockedAtText, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsedDate)
? parsedDate : (DateTimeOffset?)null;
var badge = ProviderJson.String(item, "badgeName");
items.Add(new(GameProviderHub.DeterministicGuid($"retroachievements|achievement|{id}"),
id.ToString(CultureInfo.InvariantCulture), ProviderJson.String(item, "title") ?? $"Achievement {id}",
ProviderJson.String(item, "description"), ProviderJson.Int(item, "points") ?? 0,
unlockedAt is not null, unlockedAt, badge is null ? null : $"https://media.retroachievements.org/Badge/{Uri.EscapeDataString(badge)}.png"));
}
}
return new(gameId, "RetroAchievements", externalId, items.Count(item => item.Unlocked), items.Count,
DateTimeOffset.UtcNow, items);
}
private static void Add(List<MetadataFieldCandidate> fields, string field, string? value, string externalId)
{
if (!string.IsNullOrWhiteSpace(value)) fields.Add(new(field, value.Trim(), "RetroAchievements", externalId, Confidence.High, "web-api-v1"));
}
private sealed record RetroGame(int Id, string Title);
}
internal static class ProviderHttp
{
public static async Task<string> ReadTextAsync(HttpContent content, int maximumBytes, CancellationToken cancellationToken)
{
if (content.Headers.ContentLength is > 0 && content.Headers.ContentLength > maximumBytes)
throw new InvalidDataException($"Provider response exceeds the {maximumBytes / 1024 / 1024} MiB safety limit.");
await using var input = await content.ReadAsStreamAsync(cancellationToken);
using var output = new MemoryStream(Math.Min(maximumBytes, 64 * 1024));
var buffer = new byte[32 * 1024];
while (true)
{
var read = await input.ReadAsync(buffer, cancellationToken);
if (read == 0) break;
if (output.Length + read > maximumBytes) throw new InvalidDataException("Provider response exceeds its safety limit.");
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
}
return Encoding.UTF8.GetString(output.GetBuffer(), 0, checked((int)output.Length));
}
}
internal static class ProviderText
{
public static string Normalize(string? value) => GameArtworkEnricher.Normalize(value ?? "");
public static string? NormalizePlatform(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var normalized = Normalize(value);
return normalized switch
{
"mega drive" or "sega mega drive" or "sega genesis" => "genesis",
"sega master system" => "master-system",
"sega game gear" => "game-gear",
"sony playstation" or "playstation" or "playstation 1" => "psx",
"sony playstation 2" or "playstation 2" => "ps2",
"sony psp" or "playstation portable" => "psp",
"nintendo entertainment system" or "famicom" => "nes",
"super nintendo entertainment system" or "super famicom" => "snes",
"nintendo game boy" => "gb",
"nintendo game boy color" => "gbc",
"nintendo game boy advance" => "gba",
"nintendo ds" => "nds",
"nintendo 64" => "n64",
"nintendo gamecube" => "gamecube",
"commodore 64" => "c64",
"ms dos" => "dos",
_ => normalized.Replace(' ', '-')
};
}
public static bool PlatformMatches(string normalized, params string?[] candidates) => candidates
.Where(item => !string.IsNullOrWhiteSpace(item)).Select(NormalizePlatform).Any(item => item == normalized);
}
internal static class ProviderJson
{
public static string? String(JsonElement item, string name)
{
if (!TryGet(item, name, out var value)) return null;
return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ValueKind == JsonValueKind.Number ? value.GetRawText() : null;
}
public static int? Int(JsonElement item, string name) => TryGet(item, name, out var value) && value.TryGetInt32(out var result) ? result : null;
public static long? Long(JsonElement item, string name) => TryGet(item, name, out var value) && value.TryGetInt64(out var result) ? result : null;
public static bool? Bool(JsonElement item, string name) => TryGet(item, name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False ? value.GetBoolean() : null;
public static JsonElement? Object(JsonElement item, string name) => TryGet(item, name, out var value) && value.ValueKind == JsonValueKind.Object ? value : null;
public static IEnumerable<JsonElement> Array(JsonElement item, string name) => TryGet(item, name, out var value) && value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().ToArray() : [];
private static bool TryGet(JsonElement item, string name, out JsonElement value)
{
if (item.ValueKind == JsonValueKind.Object)
foreach (var property in item.EnumerateObject())
if (property.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) { value = property.Value; return true; }
value = default;
return false;
}
}
@@ -0,0 +1,107 @@
using System.Text;
using System.Text.Json;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.Infrastructure;
public sealed class InventoryExportService(ILudariumStore store, string exportRoot) : IInventoryExportService
{
private const string SchemaVersion = "1";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<InventoryExportResult> CreateAsync(string format, CancellationToken cancellationToken)
{
var normalized = format.ToLowerInvariant() switch { "json" => "json", "csv" => "csv", "sha256" => "sha256", _ => throw new ArgumentException("Format must be json, csv or sha256.", nameof(format)) };
Directory.CreateDirectory(exportRoot);
var createdAt = DateTimeOffset.UtcNow;
var stamp = createdAt.ToString("yyyyMMdd-HHmmss-fff", System.Globalization.CultureInfo.InvariantCulture);
var fileName = normalized switch { "csv" => $"artifacts-{stamp}.csv", "sha256" => $"SHA256SUMS-{stamp}", _ => $"inventory-{stamp}.json" };
var target = Path.Combine(Path.GetFullPath(exportRoot), fileName);
try
{
var count = normalized switch
{
"json" => await WriteJsonAsync(target, createdAt, cancellationToken),
"csv" => await WriteCsvAsync(target, cancellationToken),
_ => await WriteSha256Async(target, cancellationToken)
};
return new(fileName, normalized, count, createdAt);
}
catch
{
if (File.Exists(target)) File.Delete(target);
throw;
}
}
private async Task<long> WriteJsonAsync(string target, DateTimeOffset createdAt, CancellationToken ct)
{
await using var output = NewOutput(target);
using var writer = new Utf8JsonWriter(output, new JsonWriterOptions { Indented = true });
writer.WriteStartObject();
writer.WriteString("schemaVersion", SchemaVersion);
writer.WriteString("applicationVersion", ReleaseIdentity.Version);
writer.WriteString("createdAt", createdAt);
writer.WriteStartArray("artifacts");
long count = 0;
await foreach (var artifact in ReadPagesAsync(ct))
{
JsonSerializer.Serialize(writer, artifact, JsonOptions);
count++;
if (count % 200 == 0) await writer.FlushAsync(ct);
}
writer.WriteEndArray();
writer.WriteEndObject();
await writer.FlushAsync(ct);
return count;
}
private async Task<long> WriteCsvAsync(string target, CancellationToken ct)
{
await using var output = NewOutput(target);
await using var writer = new StreamWriter(output, new UTF8Encoding(false), leaveOpen: false);
await writer.WriteLineAsync($"# ludarium-schema-version={SchemaVersion}".AsMemory(), ct);
await writer.WriteLineAsync($"# ludarium-application-version={ReleaseIdentity.Version}".AsMemory(), ct);
await writer.WriteLineAsync("id,libraryId,path,size,state,type".AsMemory(), ct);
long count = 0;
await foreach (var artifact in ReadPagesAsync(ct))
{
await writer.WriteLineAsync($"{artifact.Id},{artifact.LibraryId},{Csv(artifact.RelativePath)},{artifact.Size},{artifact.State},{artifact.MediaType}".AsMemory(), ct);
count++;
}
return count;
}
private async Task<long> WriteSha256Async(string target, CancellationToken ct)
{
await using var output = NewOutput(target);
await using var writer = new StreamWriter(output, new UTF8Encoding(false), leaveOpen: false);
await writer.WriteLineAsync($"# ludarium-schema-version={SchemaVersion}".AsMemory(), ct);
await writer.WriteLineAsync($"# ludarium-application-version={ReleaseIdentity.Version}".AsMemory(), ct);
long count = 0;
await foreach (var artifact in ReadPagesAsync(ct))
{
if (artifact.Sha256 is null) continue;
await writer.WriteLineAsync($"{artifact.Sha256} {EscapePath(artifact.RelativePath)}".AsMemory(), ct);
count++;
}
return count;
}
private async IAsyncEnumerable<Artifact> ReadPagesAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
const int pageSize = 200;
for (var pageNumber = 1; ; pageNumber++)
{
ct.ThrowIfCancellationRequested();
var page = await store.SearchArtifactsAsync(null, pageNumber, pageSize, ct);
foreach (var artifact in page.Items) yield return artifact;
if ((long)pageNumber * pageSize >= page.Total || page.Items.Count == 0) yield break;
}
}
private static FileStream NewOutput(string target) => new(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, FileOptions.Asynchronous);
private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
private static string EscapePath(string value) => value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\r", "\\r", StringComparison.Ordinal).Replace("\n", "\\n", StringComparison.Ordinal);
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Ludarium.Domain\Ludarium.Domain.csproj" />
<ProjectReference Include="..\Ludarium.Application\Ludarium.Application.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql" Version="10.0.3" />
</ItemGroup>
</Project>
+94
View File
@@ -0,0 +1,94 @@
using System.IO.Compression;
using Ludarium.Application;
namespace Ludarium.Infrastructure;
public static class N64ZipRom
{
private const int MaximumEntries = 1024;
private static readonly HashSet<string> Extensions = new(StringComparer.OrdinalIgnoreCase)
{ ".n64", ".z64", ".v64" };
public static async Task<bool> ValidateAsync(Stream source, CancellationToken ct)
{
try
{
using var archive = new ZipArchive(source, ZipArchiveMode.Read, leaveOpen: true);
var entry = SelectEntry(archive);
if (entry is null) return false;
await using var content = entry.Open();
var header = new byte[4];
var offset = 0;
while (offset < header.Length)
{
var read = await content.ReadAsync(header.AsMemory(offset), ct);
if (read == 0) return false;
offset += read;
}
return header is [0x80, 0x37, 0x12, 0x40] or [0x37, 0x80, 0x40, 0x12] or
[0x40, 0x12, 0x37, 0x80];
}
catch (Exception exception) when (exception is InvalidDataException or IOException or NotSupportedException)
{
return false;
}
}
public static async Task<BrowserPlayContent> OpenAsync(Stream source, CancellationToken ct)
{
ZipArchive? archive = null;
try
{
archive = new ZipArchive(source, ZipArchiveMode.Read, leaveOpen: true);
var entry = SelectEntry(archive) ?? throw new InvalidDataException(
"The N64 ZIP must contain exactly one bounded ROM entry.");
await using (var probe = entry.Open())
{
var header = new byte[4];
await probe.ReadExactlyAsync(header, ct);
if (header is not ([0x80, 0x37, 0x12, 0x40] or [0x37, 0x80, 0x40, 0x12] or
[0x40, 0x12, 0x37, 0x80]))
throw new InvalidDataException("The N64 ROM header is invalid.");
}
var content = new OwnedZipEntryStream(entry.Open(), archive, source, entry.Length);
archive = null;
return new(content, BrowserPlayPolicy.SafeFileName(entry.FullName), entry.Length, null);
}
catch
{
archive?.Dispose();
await source.DisposeAsync();
throw;
}
}
private static ZipArchiveEntry? SelectEntry(ZipArchive archive)
{
if (archive.Entries.Count is 0 or > MaximumEntries) return null;
var candidates = archive.Entries.Where(entry => !string.IsNullOrEmpty(entry.Name) &&
Extensions.Contains(Path.GetExtension(entry.Name)) && entry.Length is > 0 and <= BrowserPlayPolicy.MaximumRomBytes)
.Take(2).ToArray();
return candidates.Length == 1 ? candidates[0] : null;
}
private sealed class OwnedZipEntryStream(Stream content, ZipArchive archive, Stream source, long length) : Stream
{
public override bool CanRead => content.CanRead;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => length;
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override void Flush() => content.Flush();
public override int Read(byte[] buffer, int offset, int count) => content.Read(buffer, offset, count);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default) =>
content.ReadAsync(buffer, cancellationToken);
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing) { content.Dispose(); archive.Dispose(); source.Dispose(); }
base.Dispose(disposing);
}
}
}
@@ -0,0 +1,259 @@
using System.Security.Cryptography;
using System.Text;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.Infrastructure;
/// <summary>
/// Creates short-lived, capability-scoped browser sessions. The service never accepts a source path
/// from the browser: a session is always resolved back to the exact scan-derived artifact link.
/// </summary>
public sealed class NativeBrowserPlayService(
IBrowserPlayStore store,
ILudariumStore catalog,
IReadOnlyLibraryFileSystem files,
bool emulatorAssetsAvailable,
int maximumConcurrentSessions = 2,
IFirmwareStore? firmwareStore = null,
FirmwareVaultFileStore? firmwareFiles = null) : IBrowserPlayService
{
private readonly int maximumConcurrentSessions = Math.Clamp(maximumConcurrentSessions, 1, 8);
public async Task<BrowserPlayCapability> GetCapabilityAsync(Guid gameId, CancellationToken ct)
{
var candidate = await store.GetBrowserPlayCandidateAsync(gameId, ct);
var capability = BrowserPlayPolicy.Evaluate(candidate, configured: true, emulatorAssetsAvailable,
DateTimeOffset.UtcNow);
if (candidate is not null && capability.Available && IsN64Zip(candidate))
{
var validation = await ValidateN64ZipAsync(candidate, ct);
if (validation is not N64ZipValidation.Valid)
capability = capability with
{
Available = false,
State = validation is N64ZipValidation.SourceUnavailable
? BrowserPlayState.MissingRom
: BrowserPlayState.UnsupportedFormat,
Message = validation is N64ZipValidation.SourceUnavailable
? "The scan-derived ROM is no longer present or readable. Run a quick scan to reconcile the library."
: "The N64 ZIP must contain exactly one bounded ROM with a verified N64 header."
};
}
if (candidate is not null && capability.Available && DiscSetArchive.IsDescriptor(candidate.RelativePath) &&
await ResolveDiscSetAsync(candidate, ct) is null)
capability = capability with
{
Available = false,
State = BrowserPlayState.UnsupportedFormat,
Message = DiscSetArchive.IsPlaylist(candidate.RelativePath)
? "The playlist does not resolve to a complete, bounded set of present read-only discs."
: "The CUE sheet does not resolve to a complete, bounded set of present read-only track files."
};
return capability with
{ GameId = candidate?.GameId ?? gameId };
}
public async Task<BrowserPlayLaunch> StartAsync(Guid gameId, string actor, CancellationToken ct)
{
var candidate = await store.GetBrowserPlayCandidateAsync(gameId, ct)
?? throw new KeyNotFoundException("No unique present ROM is linked to this game.");
var capability = BrowserPlayPolicy.Evaluate(candidate, configured: true, emulatorAssetsAvailable,
DateTimeOffset.UtcNow);
if (capability.Available && IsN64Zip(candidate))
{
var validation = await ValidateN64ZipAsync(candidate, ct);
if (validation is N64ZipValidation.SourceUnavailable)
throw new ResourceConflictException("The scan-derived ROM is no longer present or readable. Run a quick scan to reconcile the library.");
if (validation is not N64ZipValidation.Valid)
throw new ResourceConflictException("The N64 ZIP must contain exactly one bounded ROM with a verified N64 header.");
}
if (capability.Available && DiscSetArchive.IsDescriptor(candidate.RelativePath) &&
await ResolveDiscSetAsync(candidate, ct) is null)
throw new ResourceConflictException(DiscSetArchive.IsPlaylist(candidate.RelativePath)
? "The playlist does not resolve to a complete, bounded set of present read-only discs."
: "The CUE sheet does not resolve to a complete, bounded set of present read-only track files.");
if (!capability.Available) throw new ResourceConflictException(capability.Message);
if (await store.CountActiveBrowserPlaySessionsAsync(DateTimeOffset.UtcNow, ct) >= maximumConcurrentSessions)
throw new ResourceConflictException("Browser-play capacity is currently in use. Exit another session and try again.");
var platform = BrowserPlayPolicy.GetPlatform(candidate.Platform)!;
var id = Guid.NewGuid();
var secret = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
var now = DateTimeOffset.UtcNow;
var firmwareQuery = await HasBrowserFirmwareAsync(platform.Platform, ct) ? "&firmware=1" : string.Empty;
var playerPage = platform.Core switch
{
"playjs" => "/ps2-player.html",
"n64wasm" => "/n64-player.html",
_ => "/player.html"
};
var emulator = BrowserPlayPolicy.EmulatorName(platform.Core);
var safeLaunchUrl = $"{playerPage}?session={id:D}&core={Uri.EscapeDataString(platform.Core)}&name={Uri.EscapeDataString(candidate.GameTitle)}{firmwareQuery}";
var stored = new BrowserPlaySession(id, gameId, candidate.ArtifactId, PlaySessionState.Ready,
platform.Platform, emulator, platform.Core, safeLaunchUrl, Sha256(secret), now,
now.AddHours(2));
await store.SaveBrowserPlaySessionAsync(stored, actor, ct);
// The capability secret lives in the fragment so it is never sent with the initial page request
// or persisted in the database. player.js forwards it only to the scoped content endpoint.
var launch = stored with { LaunchUrl = $"{safeLaunchUrl}#token={secret}", TokenHash = string.Empty };
return new(launch, secret);
}
public async Task<BrowserPlaySession?> GetSessionAsync(Guid sessionId, CancellationToken ct)
{
var session = await store.GetBrowserPlaySessionAsync(sessionId, ct);
if (session is null) return null;
if (IsOpen(session.State) && session.ExpiresAt <= DateTimeOffset.UtcNow)
{
await store.EndBrowserPlaySessionAsync(session.Id, PlaySessionState.Expired, "system", "expired",
"Session expired.", ct);
return session with
{
State = PlaySessionState.Expired,
EndedAt = DateTimeOffset.UtcNow,
ErrorCode = "expired",
ErrorMessage = "Session expired.",
TokenHash = string.Empty
};
}
return session with { TokenHash = string.Empty };
}
public async Task<BrowserPlayContent?> OpenContentAsync(Guid sessionId, string token, CancellationToken ct)
{
var source = await AuthorizeSourceAsync(sessionId, token, ct);
if (source is null) return null;
if (DiscSetArchive.IsDescriptor(source.Details.Artifact.RelativePath))
{
var set = await ResolveDiscSetAsync(source.Details, source.Session.Platform, ct);
return set is null ? null : DiscSetArchive.Open(set, files, source.Details.Library, ct);
}
var content = await files.OpenReadAsync(source.Details.Library, source.Details.Artifact.RelativePath, ct);
if (source.Session.Platform.Equals("n64", StringComparison.OrdinalIgnoreCase) &&
Path.GetExtension(source.Details.Artifact.RelativePath).Equals(".zip", StringComparison.OrdinalIgnoreCase))
return await N64ZipRom.OpenAsync(content, ct);
return new(content, BrowserPlayPolicy.SafeFileName(source.Details.Artifact.RelativePath),
source.Details.Artifact.Size, source.Details.Artifact.Sha256);
}
public async Task<FirmwareContent?> OpenFirmwareAsync(Guid sessionId, string token, CancellationToken ct)
{
var session = await AuthorizeAsync(sessionId, token, ct);
if (session is null || firmwareStore is null || firmwareFiles is null) return null;
FirmwareRequirement requirement;
try { requirement = FirmwarePolicy.Get(session.Platform, "bios"); }
catch (FirmwareValidationException) { return null; }
if (!requirement.BrowserDelivery) return null;
var asset = await firmwareStore.GetSelectedFirmwareAssetAsync(requirement.Platform, requirement.Slot, ct);
return asset is null ? null : await firmwareFiles.OpenAsync(asset, ct);
}
public async Task<AuthorizedBrowserPlaySource?> AuthorizeSourceAsync(Guid sessionId, string token,
CancellationToken ct)
{
var session = await AuthorizeAsync(sessionId, token, ct);
if (session is null) return null;
var candidate = await store.GetBrowserPlayCandidateAsync(session.GameId, ct);
var capability = BrowserPlayPolicy.Evaluate(candidate, true, emulatorAssetsAvailable, DateTimeOffset.UtcNow);
if (candidate is null || candidate.ArtifactId != session.ArtifactId || !capability.Available ||
!string.Equals(capability.Core, session.Core, StringComparison.OrdinalIgnoreCase)) return null;
var details = await catalog.GetArtifactDetailsAsync(session.ArtifactId, ct);
if (details is null || details.Library.Id == Guid.Empty || details.Library.IsReadOnly != true ||
details.Artifact.State is not ArtifactState.Present || details.Artifact.Size != candidate.Size ||
!details.Artifact.RelativePath.Equals(candidate.RelativePath, StringComparison.Ordinal)) return null;
if (IsN64Zip(candidate) && await ValidateN64ZipAsync(candidate, details, ct) is not N64ZipValidation.Valid) return null;
if (DiscSetArchive.IsDescriptor(details.Artifact.RelativePath) &&
await ResolveDiscSetAsync(details, session.Platform, ct) is null) return null;
return new(session, details);
}
public async Task<BrowserPlaySession?> AuthorizeAsync(Guid sessionId, string token, CancellationToken ct)
{
var session = await store.GetBrowserPlaySessionAsync(sessionId, ct);
return session is not null && IsOpen(session.State) && session.ExpiresAt > DateTimeOffset.UtcNow &&
MatchesToken(token, session.TokenHash) ? session with { TokenHash = string.Empty } : null;
}
public Task CancelAsync(Guid sessionId, string actor, CancellationToken ct) =>
store.EndBrowserPlaySessionAsync(sessionId, PlaySessionState.Cancelled, actor, "cancelled",
"Exited by the operator.", ct);
private async Task<bool> HasBrowserFirmwareAsync(string platform, CancellationToken ct)
{
if (firmwareStore is null) return false;
FirmwareRequirement requirement;
try { requirement = FirmwarePolicy.Get(platform, "bios"); }
catch (FirmwareValidationException) { return false; }
return requirement.BrowserDelivery &&
await firmwareStore.GetSelectedFirmwareAssetAsync(requirement.Platform, requirement.Slot, ct) is not null;
}
/// <summary>
/// Resolve a linked CUE sheet or M3U playlist to its complete set. The platform's own size limit
/// bounds the whole game, not just the descriptor, so an oversized or incomplete set never
/// becomes playable.
/// </summary>
private async Task<DiscSet?> ResolveDiscSetAsync(BrowserPlayCandidate candidate, CancellationToken ct)
{
var details = await catalog.GetArtifactDetailsAsync(candidate.ArtifactId, ct);
return details is null ? null : await ResolveDiscSetAsync(details, candidate.Platform, ct);
}
private async Task<DiscSet?> ResolveDiscSetAsync(ArtifactDetails details, string platform, CancellationToken ct)
{
if (details.Library.IsReadOnly != true || details.Artifact.State is not ArtifactState.Present) return null;
var maximum = BrowserPlayPolicy.GetPlatform(platform)?.MaximumBytes ?? BrowserPlayPolicy.MaximumRomBytes;
return await DiscSetArchive.ResolveAsync(files, details.Library, details.Artifact.RelativePath,
details.Artifact.Size, maximum, ct);
}
private static bool IsN64Zip(BrowserPlayCandidate candidate) =>
candidate.Platform.Equals("n64", StringComparison.OrdinalIgnoreCase) &&
Path.GetExtension(candidate.RelativePath).Equals(".zip", StringComparison.OrdinalIgnoreCase);
private enum N64ZipValidation { Valid, Invalid, SourceUnavailable }
private async Task<N64ZipValidation> ValidateN64ZipAsync(BrowserPlayCandidate candidate, CancellationToken ct)
{
var details = await catalog.GetArtifactDetailsAsync(candidate.ArtifactId, ct);
return details is null ? N64ZipValidation.Invalid : await ValidateN64ZipAsync(candidate, details, ct);
}
private async Task<N64ZipValidation> ValidateN64ZipAsync(BrowserPlayCandidate candidate, ArtifactDetails details,
CancellationToken ct)
{
if (details.Library.IsReadOnly != true || details.Artifact.State is not ArtifactState.Present ||
details.Artifact.Size != candidate.Size ||
!details.Artifact.RelativePath.Equals(candidate.RelativePath, StringComparison.Ordinal))
return N64ZipValidation.Invalid;
try
{
await using var archive = await files.OpenReadAsync(details.Library, details.Artifact.RelativePath, ct);
return await N64ZipRom.ValidateAsync(archive, ct)
? N64ZipValidation.Valid
: N64ZipValidation.Invalid;
}
catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException or
UnauthorizedAccessException)
{
return N64ZipValidation.SourceUnavailable;
}
}
private static bool IsOpen(PlaySessionState state) => state is
PlaySessionState.Starting or PlaySessionState.Ready or PlaySessionState.Active;
private static bool MatchesToken(string value, string expectedHash)
{
if (string.IsNullOrWhiteSpace(value) || expectedHash.Length != 64) return false;
var actual = Encoding.ASCII.GetBytes(Sha256(value));
var expected = Encoding.ASCII.GetBytes(expectedHash);
return CryptographicOperations.FixedTimeEquals(actual, expected);
}
private static string Sha256(string value) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
}
@@ -0,0 +1,128 @@
using Ludarium.Application;
using Ludarium.Domain;
using Npgsql;
namespace Ludarium.Infrastructure;
public sealed partial class PostgresStore
{
private static readonly string[] DolphinExtensionPatterns = ["%.iso", "%.gcm", "%.rvz", "%.gcz", "%.wbfs", "%.wia"];
public async Task<IReadOnlyList<NativeLaunchCandidate>> ListDolphinLaunchCandidatesAsync(Guid gameId,
string platform, CancellationToken ct)
{
if (platform is not ("gamecube" or "wii")) return [];
const string sql = """
SELECT DISTINCT a.id,a.relative_path,a.size,coalesce((l.data->>'isReadOnly')::boolean,false)
FROM game_artifact_links x
JOIN artifacts a ON a.id=x.artifact_id
JOIN library_roots l ON l.id=a.library_id
WHERE x.game_id=$1 AND a.data->>'state'='0' AND a.data->>'platform'=$2
AND lower(a.relative_path) LIKE ANY($3)
ORDER BY a.relative_path
LIMIT 3
""";
await using var command = dataSource.CreateCommand(sql);
command.Parameters.AddWithValue(gameId);
command.Parameters.AddWithValue(platform);
command.Parameters.AddWithValue(DolphinExtensionPatterns);
var result = new List<NativeLaunchCandidate>();
await using var reader = await command.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
result.Add(new(reader.GetGuid(0), reader.GetString(1), reader.GetInt64(2), reader.GetBoolean(3)));
return result;
}
public async Task<IReadOnlyList<NativeLaunchCandidate>> ListSwitchLaunchCandidatesAsync(Guid gameId, CancellationToken ct)
{
const string sql = """
WITH linked AS (
SELECT a.library_id,regexp_replace(a.relative_path,'(^|/)[^/]+$','') AS directory
FROM game_artifact_links x JOIN artifacts a ON a.id=x.artifact_id
WHERE x.game_id=$1 AND a.data->>'platform'='switch'
)
SELECT DISTINCT a.id,a.relative_path,a.size,coalesce((l.data->>'isReadOnly')::boolean,false)
FROM linked x
JOIN artifacts a ON a.library_id=x.library_id AND regexp_replace(a.relative_path,'(^|/)[^/]+$','')=x.directory
JOIN library_roots l ON l.id=a.library_id
WHERE a.data->>'state'='0' AND a.data->>'platform'='switch'
AND (lower(a.relative_path) LIKE '%.xci' OR lower(a.relative_path) LIKE '%.nsp')
ORDER BY a.relative_path
LIMIT 100
""";
await using var command = dataSource.CreateCommand(sql);
command.Parameters.AddWithValue(gameId);
var result = new List<NativeLaunchCandidate>();
await using var reader = await command.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
result.Add(new(reader.GetGuid(0), reader.GetString(1), reader.GetInt64(2), reader.GetBoolean(3)));
return result;
}
public async Task<BrowserPlayCandidate?> GetBrowserPlayCandidateAsync(Guid gameId, CancellationToken ct)
{
// Descriptors are included so a CUE/BIN disc or a multi-disc playlist can be played as a
// set. Their members are linked to the same game and would otherwise make every
// multi-track or multi-disc release look ambiguous.
const string sql = """
SELECT g.id,g.title,a.id,a.relative_path,a.size,a.sha256,
a.data->>'platform',coalesce((l.data->>'isReadOnly')::boolean,false)
FROM game_artifact_links x
JOIN games g ON g.id=x.game_id
JOIN artifacts a ON a.id=x.artifact_id
JOIN library_roots l ON l.id=a.library_id
WHERE x.game_id=$1 AND a.data->>'state'='0' AND
(a.data->>'mediaType' IN ('0','2') OR
(a.data->>'mediaType'='1' AND (lower(a.relative_path) LIKE '%.cue'
OR lower(a.relative_path) LIKE '%.m3u')) OR
(a.data->>'mediaType'='3' AND a.data->>'platform'='n64' AND lower(a.relative_path) LIKE '%.zip'))
ORDER BY a.relative_path LIMIT 64
""";
await using var command = dataSource.CreateCommand(sql);
command.Parameters.AddWithValue(gameId);
var candidates = new List<BrowserPlayCandidate>();
await using var reader = await command.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var platform = reader.IsDBNull(6) ? null : reader.GetString(6);
if (!string.IsNullOrWhiteSpace(platform))
candidates.Add(new(reader.GetGuid(0), reader.GetGuid(2), reader.GetString(1), platform,
reader.GetString(3), reader.GetInt64(4), reader.IsDBNull(5) ? null : reader.GetString(5), reader.GetBoolean(7)));
}
return BrowserPlayPolicy.SelectCandidate(candidates);
}
public async Task<int> CountActiveBrowserPlaySessionsAsync(DateTimeOffset now, CancellationToken ct)
{
await using var command = dataSource.CreateCommand("SELECT count(*)::int FROM browser_play_sessions WHERE state IN (0,1,2) AND expires_at>$1");
command.Parameters.AddWithValue(now);
return Convert.ToInt32(await command.ExecuteScalarAsync(ct), System.Globalization.CultureInfo.InvariantCulture);
}
public async Task SaveBrowserPlaySessionAsync(BrowserPlaySession session, string actor, CancellationToken ct)
{
var audit = new AuditEvent(Guid.NewGuid(), actor, "BrowserPlayStarted", "Game", session.GameId,
null, System.Text.Json.JsonSerializer.Serialize(new { session.Id, session.Platform, session.Emulator, session.Core, session.ExpiresAt }),
Guid.NewGuid(), session.CreatedAt);
await ExecuteTransactionAsync(ct,
("INSERT INTO browser_play_sessions(id,game_id,artifact_id,state,expires_at,data,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$7)",
[session.Id, session.GameId, session.ArtifactId, (int)session.State, session.ExpiresAt, Json(session), session.CreatedAt]),
("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt]));
}
public Task<BrowserPlaySession?> GetBrowserPlaySessionAsync(Guid sessionId, CancellationToken ct) =>
SingleJsonAsync<BrowserPlaySession>("SELECT data FROM browser_play_sessions WHERE id=$1", ct, sessionId);
public async Task EndBrowserPlaySessionAsync(Guid sessionId, PlaySessionState state, string actor,
string? errorCode, string? errorMessage, CancellationToken ct)
{
var current = await GetBrowserPlaySessionAsync(sessionId, ct);
if (current is null) return;
var ended = current with { State = state, EndedAt = DateTimeOffset.UtcNow, ErrorCode = errorCode, ErrorMessage = errorMessage };
var audit = new AuditEvent(Guid.NewGuid(), actor, "BrowserPlayEnded", "Game", current.GameId,
null, System.Text.Json.JsonSerializer.Serialize(new { sessionId, state, errorCode }), Guid.NewGuid(), ended.EndedAt.Value);
await ExecuteTransactionAsync(ct,
("UPDATE browser_play_sessions SET state=$2,data=$3,updated_at=$4 WHERE id=$1", [sessionId, (int)state, Json(ended), ended.EndedAt.Value]),
("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt]));
}
}
@@ -0,0 +1,44 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.Infrastructure;
public sealed partial class PostgresStore
{
public async Task<IReadOnlyList<FirmwareAsset>> ListFirmwareAssetsAsync(CancellationToken ct) =>
await ListJsonAsync<FirmwareAsset>("SELECT data FROM firmware_assets ORDER BY platform,slot,created_at DESC", ct);
public Task<FirmwareAsset?> GetFirmwareAssetAsync(Guid id, CancellationToken ct) =>
SingleJsonAsync<FirmwareAsset>("SELECT data FROM firmware_assets WHERE id=$1", ct, id);
public Task<FirmwareAsset?> GetSelectedFirmwareAssetAsync(string platform, string slot, CancellationToken ct) =>
SingleJsonAsync<FirmwareAsset>("SELECT data FROM firmware_assets WHERE platform=$1 AND slot=$2 AND selected=true", ct,
platform.ToLowerInvariant(), slot.ToLowerInvariant());
public async Task SaveFirmwareAssetAsync(FirmwareAsset asset, string actor, CancellationToken ct)
{
var audit = new AuditEvent(Guid.NewGuid(), actor, "FirmwareUploaded", "FirmwareAsset", asset.Id, null,
System.Text.Json.JsonSerializer.Serialize(new { asset.Platform, asset.Slot, asset.Kind, asset.FileName, asset.Length, Sha256Prefix = asset.Sha256[..12] }),
Guid.NewGuid(), asset.CreatedAt);
await ExecuteTransactionAsync(ct,
("UPDATE firmware_assets SET selected=false,data=jsonb_set(data,'{selected}','false'::jsonb,true),updated_at=$3 WHERE platform=$1 AND slot=$2 AND selected=true",
[asset.Platform, asset.Slot, asset.CreatedAt]),
("INSERT INTO firmware_assets(id,platform,slot,selected,version,data,created_at,updated_at) VALUES($1,$2,$3,true,1,$4,$5,$5)",
[asset.Id, asset.Platform, asset.Slot, Json(asset), asset.CreatedAt]),
("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt]));
}
public async Task<IReadOnlyList<FirmwareAsset>> DeleteFirmwareAssetAsync(Guid id, long expectedVersion,
string actor, CancellationToken ct)
{
var current = await GetFirmwareAssetAsync(id, ct) ?? throw new KeyNotFoundException("Firmware asset was not found.");
if (current.Version != expectedVersion) throw new ConcurrencyException("Firmware changed since it was loaded.");
var audit = new AuditEvent(Guid.NewGuid(), actor, "FirmwareDeleted", "FirmwareAsset", id,
System.Text.Json.JsonSerializer.Serialize(new { current.Platform, current.Slot, current.Kind, current.FileName, current.Length, Sha256Prefix = current.Sha256[..12] }),
null, Guid.NewGuid(), DateTimeOffset.UtcNow);
await ExecuteTransactionAsync(ct,
("DELETE FROM firmware_assets WHERE id=$1 AND version=$2", [id, expectedVersion]),
("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt]));
return [current];
}
}
@@ -0,0 +1,308 @@
using System.Globalization;
using Ludarium.Application;
using Ludarium.Domain;
using Npgsql;
namespace Ludarium.Infrastructure;
public sealed partial class PostgresStore
{
public async Task<IReadOnlyList<GameDataItem>> ListGameDataAsync(Guid gameId, CancellationToken ct)
{
await RequireGameAsync(gameId, ct);
await using var command = dataSource.CreateCommand("""
SELECT e.data,r.data
FROM game_data_entries e
JOIN game_data_revisions r ON r.id=e.current_revision_id
WHERE e.game_id=$1
ORDER BY e.kind,lower(e.data->>'name'),e.updated_at DESC,e.id
""");
Add(command, gameId);
var result = new List<GameDataItem>();
await using var reader = await command.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
result.Add(new(Deserialize<GameDataEntry>(reader.GetString(0)),
Deserialize<GameDataRevision>(reader.GetString(1))));
return result;
}
public async Task<GameDataItem?> GetGameDataAsync(Guid entryId, CancellationToken ct)
{
await using var command = dataSource.CreateCommand("""
SELECT e.data,r.data
FROM game_data_entries e
JOIN game_data_revisions r ON r.id=e.current_revision_id
WHERE e.id=$1
""");
Add(command, entryId);
await using var reader = await command.ExecuteReaderAsync(ct);
return await reader.ReadAsync(ct)
? new(Deserialize<GameDataEntry>(reader.GetString(0)), Deserialize<GameDataRevision>(reader.GetString(1)))
: null;
}
public async Task<Page<GameDataRevision>> ListGameDataRevisionsAsync(Guid entryId, int page,
int pageSize, CancellationToken ct)
{
if (await GetGameDataAsync(entryId, ct) is null) throw new KeyNotFoundException("Game-data entry not found.");
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 1, 100);
await using var count = dataSource.CreateCommand("SELECT count(*) FROM game_data_revisions WHERE entry_id=$1");
Add(count, entryId);
var total = Convert.ToInt64(await count.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
await using var command = dataSource.CreateCommand($"""
SELECT data FROM game_data_revisions
WHERE entry_id=$1
ORDER BY sequence DESC,id
LIMIT {pageSize} OFFSET {(page - 1) * pageSize}
""");
Add(command, entryId);
return new(await ReadAsync<GameDataRevision>(command, ct), page, pageSize, total);
}
public Task<GameDataRevision?> GetGameDataRevisionAsync(Guid revisionId, CancellationToken ct) =>
SingleJsonAsync<GameDataRevision>("SELECT data FROM game_data_revisions WHERE id=$1", revisionId, ct);
public async Task<GameDataItem> SaveGameDataRevisionAsync(Guid gameId, Guid entryId,
GameDataEntryInput input, long expectedVersion, GameDataRevision revision, string actor,
CancellationToken ct)
{
input = GameDataVaultPolicy.Normalize(input);
await RequireGameAsync(gameId, ct);
ValidateRevision(gameId, entryId, revision);
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
await using (var entryLock = new NpgsqlCommand("SELECT pg_advisory_xact_lock($1)", connection, transaction))
{
Add(entryLock, BitConverter.ToInt64(entryId.ToByteArray(), 0));
await entryLock.ExecuteNonQueryAsync(ct);
}
var existingRevision = await ReadRevisionAsync(connection, transaction, revision.Id, ct);
if (existingRevision is not null)
{
if (existingRevision.EntryId != entryId || !existingRevision.Sha256.Equals(revision.Sha256, StringComparison.OrdinalIgnoreCase))
throw new ResourceConflictException("The revision id is already used by different content.");
await transaction.CommitAsync(ct);
return await GetGameDataAsync(entryId, ct) ?? throw new KeyNotFoundException("Game-data entry not found.");
}
var before = await ReadEntryForUpdateAsync(connection, transaction, entryId, ct);
if (before is null)
{
if (expectedVersion != 0) throw new ConcurrencyException("The game-data entry no longer matches the requested version.");
var storedRevision = revision with { Sequence = 1 };
var now = storedRevision.CreatedAt;
var created = new GameDataEntry(entryId, gameId, input.Kind, input.Name, input.Emulator,
input.Device, input.Notes, storedRevision.Id, 1, now, now);
await using (var insertEntry = new NpgsqlCommand("""
INSERT INTO game_data_entries(id,game_id,kind,current_revision_id,version,data,created_at,updated_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8)
""", connection, transaction))
{
Add(insertEntry, created.Id, created.GameId, (int)created.Kind, created.CurrentRevisionId,
created.Version, Json(created), created.CreatedAt, created.UpdatedAt);
await insertEntry.ExecuteNonQueryAsync(ct);
}
await InsertRevisionAsync(connection, transaction, storedRevision, ct);
await InsertGameDataAuditAsync(connection, transaction, actor, "GameDataCreated", created.Id,
null, new { Entry = created, Revision = AuditRevision(storedRevision) }, now, ct);
await transaction.CommitAsync(ct);
return new(created, storedRevision);
}
if (before.GameId != gameId) throw new KeyNotFoundException("Game-data entry not found for this game.");
if (before.Kind != input.Kind) throw new ResourceConflictException("Save and savestate entries cannot change kind.");
if (before.Version != expectedVersion) throw new ConcurrencyException("The game-data entry changed after it was loaded.");
var current = await ReadRevisionAsync(connection, transaction, before.CurrentRevisionId, ct)
?? throw new InvalidDataException("The current game-data revision is missing.");
if (current.Length == revision.Length &&
current.Sha256.Equals(revision.Sha256, StringComparison.OrdinalIgnoreCase) &&
current.SourceType.Equals(revision.SourceType, StringComparison.Ordinal) &&
current.SourceId.Equals(revision.SourceId, StringComparison.Ordinal))
{
await transaction.CommitAsync(ct);
return new(before, current);
}
var nextRevision = revision with { Sequence = before.RevisionCount + 1 };
var after = before with
{
Name = input.Name,
Emulator = input.Emulator,
Device = input.Device,
Notes = input.Notes,
CurrentRevisionId = nextRevision.Id,
RevisionCount = before.RevisionCount + 1,
UpdatedAt = nextRevision.CreatedAt,
Version = before.Version + 1
};
await InsertRevisionAsync(connection, transaction, nextRevision, ct);
await using (var update = new NpgsqlCommand("""
UPDATE game_data_entries SET current_revision_id=$2,version=$3,data=$4,updated_at=$5
WHERE id=$1 AND version=$6
""", connection, transaction))
{
Add(update, entryId, after.CurrentRevisionId, after.Version, Json(after), after.UpdatedAt, expectedVersion);
if (await update.ExecuteNonQueryAsync(ct) != 1)
throw new ConcurrencyException("The game-data entry changed while the revision was being saved.");
}
await InsertGameDataAuditAsync(connection, transaction, actor, "GameDataRevisionAdded", entryId,
before, new { Entry = after, Revision = AuditRevision(nextRevision) }, after.UpdatedAt, ct);
await transaction.CommitAsync(ct);
return new(after, nextRevision);
}
public async Task<GameDataItem> UpdateGameDataAsync(Guid gameId, Guid entryId,
GameDataMetadataInput input, long expectedVersion, string actor, CancellationToken ct)
{
input = GameDataVaultPolicy.Normalize(input);
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var before = await ReadEntryForUpdateAsync(connection, transaction, entryId, ct)
?? throw new KeyNotFoundException("Game-data entry not found.");
if (before.GameId != gameId) throw new KeyNotFoundException("Game-data entry not found for this game.");
if (before.Version != expectedVersion) throw new ConcurrencyException("The game-data entry changed after it was loaded.");
var after = before with
{
Name = input.Name,
Emulator = input.Emulator,
Device = input.Device,
Notes = input.Notes,
UpdatedAt = DateTimeOffset.UtcNow,
Version = before.Version + 1
};
await using (var update = new NpgsqlCommand("""
UPDATE game_data_entries SET version=$2,data=$3,updated_at=$4 WHERE id=$1 AND version=$5
""", connection, transaction))
{
Add(update, entryId, after.Version, Json(after), after.UpdatedAt, expectedVersion);
if (await update.ExecuteNonQueryAsync(ct) != 1)
throw new ConcurrencyException("The game-data entry changed while it was being saved.");
}
await InsertGameDataAuditAsync(connection, transaction, actor, "GameDataMetadataUpdated", entryId,
before, after, after.UpdatedAt, ct);
var current = await ReadRevisionAsync(connection, transaction, after.CurrentRevisionId, ct)
?? throw new InvalidDataException("The current game-data revision is missing.");
await transaction.CommitAsync(ct);
return new(after, current);
}
public async Task<IReadOnlyList<GameDataRevision>> DeleteGameDataAsync(Guid gameId, Guid entryId,
long expectedVersion, string actor, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var before = await ReadEntryForUpdateAsync(connection, transaction, entryId, ct)
?? throw new KeyNotFoundException("Game-data entry not found.");
if (before.GameId != gameId) throw new KeyNotFoundException("Game-data entry not found for this game.");
if (before.Version != expectedVersion) throw new ConcurrencyException("The game-data entry changed after it was loaded.");
var revisions = await ReadRevisionsAsync(connection, transaction, entryId, ct);
await using (var delete = new NpgsqlCommand("DELETE FROM game_data_entries WHERE id=$1 AND version=$2", connection, transaction))
{
Add(delete, entryId, expectedVersion);
if (await delete.ExecuteNonQueryAsync(ct) != 1)
throw new ConcurrencyException("The game-data entry changed while it was being deleted.");
}
await InsertGameDataAuditAsync(connection, transaction, actor, "GameDataDeleted", entryId,
before, new { Revisions = revisions.Select(AuditRevision).ToArray() }, DateTimeOffset.UtcNow, ct);
await transaction.CommitAsync(ct);
return revisions;
}
public async Task<GameDataSummary> GetGameDataSummaryAsync(Guid? gameId, CancellationToken ct)
{
var sql = """
SELECT count(DISTINCT e.id) FILTER (WHERE e.kind=0),count(DISTINCT e.id) FILTER (WHERE e.kind=1),
count(r.id),coalesce(sum(r.length),0)
FROM game_data_entries e
LEFT JOIN game_data_revisions r ON r.entry_id=e.id
""" + (gameId is null ? string.Empty : " WHERE e.game_id=$1");
await using var command = dataSource.CreateCommand(sql);
if (gameId is not null) Add(command, gameId.Value);
await using var reader = await command.ExecuteReaderAsync(ct);
await reader.ReadAsync(ct);
return new(reader.GetInt64(0), reader.GetInt64(1), reader.GetInt64(2), reader.GetInt64(3));
}
public async Task<IReadOnlyList<string>> ListGameDataRevisionLocationsAsync(CancellationToken ct)
{
await using var command = dataSource.CreateCommand("SELECT data->>'location' FROM game_data_revisions WHERE data ? 'location' ORDER BY data->>'location'");
var result = new List<string>();
await using var reader = await command.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct)) result.Add(reader.GetString(0));
return result;
}
private static async Task<GameDataEntry?> ReadEntryForUpdateAsync(NpgsqlConnection connection,
NpgsqlTransaction transaction, Guid entryId, CancellationToken ct)
{
await using var command = new NpgsqlCommand("SELECT data FROM game_data_entries WHERE id=$1 FOR UPDATE", connection, transaction);
Add(command, entryId);
var value = await command.ExecuteScalarAsync(ct);
return value is null or DBNull ? null : Deserialize<GameDataEntry>((string)value);
}
private static async Task<GameDataRevision?> ReadRevisionAsync(NpgsqlConnection connection,
NpgsqlTransaction transaction, Guid revisionId, CancellationToken ct)
{
await using var command = new NpgsqlCommand("SELECT data FROM game_data_revisions WHERE id=$1", connection, transaction);
Add(command, revisionId);
var value = await command.ExecuteScalarAsync(ct);
return value is null or DBNull ? null : Deserialize<GameDataRevision>((string)value);
}
private static async Task<IReadOnlyList<GameDataRevision>> ReadRevisionsAsync(NpgsqlConnection connection,
NpgsqlTransaction transaction, Guid entryId, CancellationToken ct)
{
await using var command = new NpgsqlCommand("SELECT data FROM game_data_revisions WHERE entry_id=$1 ORDER BY sequence", connection, transaction);
Add(command, entryId);
return await ReadAsync<GameDataRevision>(command, ct);
}
private static async Task InsertRevisionAsync(NpgsqlConnection connection, NpgsqlTransaction transaction,
GameDataRevision revision, CancellationToken ct)
{
await using var command = new NpgsqlCommand("""
INSERT INTO game_data_revisions(id,entry_id,sequence,sha256,length,data,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7)
""", connection, transaction);
Add(command, revision.Id, revision.EntryId, revision.Sequence, revision.Sha256, revision.Length,
Json(revision), revision.CreatedAt);
await command.ExecuteNonQueryAsync(ct);
}
private static async Task InsertGameDataAuditAsync(NpgsqlConnection connection,
NpgsqlTransaction transaction, string actor, string action, Guid entryId, object? before,
object? after, DateTimeOffset at, CancellationToken ct)
{
var audit = Audit(actor, action, "GameDataEntry", entryId, before, after, at);
await using var command = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction);
Add(command, audit.Id, Json(audit), audit.CreatedAt);
await command.ExecuteNonQueryAsync(ct);
}
private static object AuditRevision(GameDataRevision revision) => new
{
revision.Id,
revision.EntryId,
revision.Sequence,
revision.FileName,
revision.Length,
revision.Sha256,
revision.SourceType,
revision.SourceId,
revision.CreatedAt
};
private static void ValidateRevision(Guid gameId, Guid entryId, GameDataRevision revision)
{
if (revision.Id == Guid.Empty || revision.EntryId != entryId || revision.Length <= 0 ||
revision.Sha256.Length != 64 || revision.Sha256.Any(character => !Uri.IsHexDigit(character)))
throw new ArgumentException("Game-data revision evidence is invalid.", nameof(revision));
var requiredPrefix = $"{gameId:N}/{entryId:N}/";
if (!revision.Location.StartsWith(requiredPrefix, StringComparison.Ordinal) ||
revision.Location.Contains("..", StringComparison.Ordinal))
throw new ArgumentException("Game-data revision location is outside its app-owned entry.", nameof(revision));
}
}
@@ -0,0 +1,503 @@
using System.Globalization;
using Ludarium.Application;
using Ludarium.Domain;
using Npgsql;
namespace Ludarium.Infrastructure;
public sealed partial class PostgresStore
{
internal static readonly (string Id, string Name, string Category, string[] Aliases, bool MetadataOnly)[] BuiltInPlatforms =
[
("windows", "Windows PC", "Computer", ["pc", "win", "windows-pc"], false),
("dos", "MS-DOS", "Computer", ["msdos"], true),
("linux", "Linux", "Computer", [], true),
("macintosh", "Apple Macintosh", "Computer", ["mac", "macos"], true),
("amiga", "Commodore Amiga", "Computer", [], true),
("amiga-cd32", "Amiga CD32", "Console", ["cd32"], true),
("c64", "Commodore 64", "Computer", ["commodore-64"], true),
("zx-spectrum", "ZX Spectrum", "Computer", ["zxspectrum"], true),
("atari-st", "Atari ST", "Computer", ["atarist"], true),
("pc-98", "NEC PC-9800", "Computer", ["pc98"], true),
("flash", "Adobe Flash", "Computer", ["swf"], true),
("scummvm", "ScummVM", "Computer", [], true),
("nes", "Nintendo Entertainment System", "Nintendo", ["famicom"], false),
("fds", "Famicom Disk System", "Nintendo", [], true),
("snes", "Super Nintendo Entertainment System", "Nintendo", ["sfc", "super-famicom"], false),
("n64", "Nintendo 64", "Nintendo", ["nintendo-64"], false),
("n64dd", "Nintendo 64DD", "Nintendo", [], true),
("gamecube", "Nintendo GameCube", "Nintendo", ["gc", "ngc"], true),
("wii", "Nintendo Wii", "Nintendo", [], true),
("wii-u", "Nintendo Wii U", "Nintendo", ["wiiu"], true),
("switch", "Nintendo Switch", "Nintendo", ["nsw"], true),
("gb", "Nintendo Game Boy", "Nintendo", ["game-boy"], false),
("gbc", "Nintendo Game Boy Color", "Nintendo", ["game-boy-color"], false),
("gba", "Nintendo Game Boy Advance", "Nintendo", ["game-boy-advance"], false),
("nds", "Nintendo DS", "Nintendo", ["ds"], false),
("3ds", "Nintendo 3DS", "Nintendo", ["n3ds"], true),
("virtual-boy", "Nintendo Virtual Boy", "Nintendo", ["vb"], true),
("pokemon-mini", "Pokémon Mini", "Nintendo", [], true),
("psx", "Sony PlayStation", "PlayStation", ["ps1", "playstation"], true),
("ps2", "Sony PlayStation 2", "PlayStation", ["playstation-2"], true),
("ps3", "Sony PlayStation 3", "PlayStation", ["playstation-3"], true),
("ps4", "Sony PlayStation 4", "PlayStation", ["playstation-4"], true),
("ps5", "Sony PlayStation 5", "PlayStation", ["playstation-5"], true),
("psp", "PlayStation Portable", "PlayStation", [], true),
("psvita", "PlayStation Vita", "PlayStation", ["vita"], true),
("xbox", "Microsoft Xbox", "Xbox", ["xbox-original"], true),
("xbox-360", "Microsoft Xbox 360", "Xbox", ["xbox360"], true),
("xbox-one", "Microsoft Xbox One", "Xbox", ["xboxone"], true),
("xbox-series", "Xbox Series X|S", "Xbox", ["series-x", "series-s"], true),
("master-system", "Sega Master System", "Sega", ["sms"], false),
("genesis", "Sega Genesis / Mega Drive", "Sega", ["megadrive", "mega-drive"], false),
("sega-cd", "Sega CD / Mega-CD", "Sega", ["megacd"], true),
("sega-32x", "Sega 32X", "Sega", ["32x"], true),
("saturn", "Sega Saturn", "Sega", [], true),
("dreamcast", "Sega Dreamcast", "Sega", ["dc"], true),
("game-gear", "Sega Game Gear", "Sega", ["gamegear", "gg"], false),
("sg-1000", "Sega SG-1000", "Sega", [], false),
("atari-2600", "Atari 2600", "Atari", ["a2600"], true),
("atari-5200", "Atari 5200", "Atari", ["a5200"], true),
("atari-7800", "Atari 7800", "Atari", ["a7800"], true),
("atari-jaguar", "Atari Jaguar", "Atari", ["jaguar"], true),
("atari-lynx", "Atari Lynx", "Atari", ["lynx"], true),
("neo-geo", "SNK Neo Geo", "SNK", ["neogeo"], true),
("neo-geo-cd", "SNK Neo Geo CD", "SNK", ["neogeocd"], true),
("neo-geo-pocket", "Neo Geo Pocket", "SNK", ["ngp"], true),
("neo-geo-pocket-color", "Neo Geo Pocket Color", "SNK", ["ngpc"], true),
("pc-engine", "NEC PC Engine / TurboGrafx-16", "NEC", ["tg16", "turbografx-16"], true),
("pc-engine-cd", "PC Engine CD", "NEC", ["tg-cd", "turbografx-cd"], true),
("pc-fx", "NEC PC-FX", "NEC", [], true),
("3do", "3DO Interactive Multiplayer", "Other", [], true),
("colecovision", "ColecoVision", "Other", [], true),
("intellivision", "Mattel Intellivision", "Other", [], true),
("odyssey2", "Magnavox Odyssey²", "Other", ["videopac"], true),
("vectrex", "GCE Vectrex", "Other", [], true),
("wonderswan", "Bandai WonderSwan", "Other", ["ws"], true),
("wonderswan-color", "Bandai WonderSwan Color", "Other", ["wsc"], true),
("arcade", "Arcade", "Arcade", ["mame", "fbneo"], true),
("pico-8", "PICO-8", "Fantasy console", ["pico8"], true)
];
private async Task SeedBuiltInPlatformsAsync(CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
foreach (var item in BuiltInPlatforms)
{
var definition = new PlatformDefinition(item.Id, item.Name, item.Category, item.Aliases,
item.MetadataOnly, false, true, DateTimeOffset.UtcNow);
await using var command = new NpgsqlCommand("INSERT INTO platform_definitions(id,custom,enabled,version,data,updated_at) VALUES($1,false,true,1,$2,$3) ON CONFLICT(id) DO NOTHING", connection, transaction);
Add(command, definition.Id, Json(definition), definition.UpdatedAt);
await command.ExecuteNonQueryAsync(ct);
}
await transaction.CommitAsync(ct);
}
public async Task<IReadOnlyList<GameGroup>> ListCollectionsAsync(CancellationToken ct)
{
var collections = await ListJsonAsync<GameGroup>("SELECT data FROM game_collections ORDER BY (data->>'pinned')::boolean DESC,lower(name)", ct);
var result = new List<GameGroup>(collections.Count);
foreach (var collection in collections)
result.Add(collection with { GameCount = await CountCollectionGamesAsync(collection, ct) });
return result;
}
public async Task<GameGroup?> GetCollectionAsync(Guid id, CancellationToken ct)
{
var collection = await SingleJsonAsync<GameGroup>("SELECT data FROM game_collections WHERE id=$1", id, ct);
return collection is null ? null : collection with { GameCount = await CountCollectionGamesAsync(collection, ct) };
}
public async Task<GameGroup> CreateCollectionAsync(GameCollectionInput input, string actor, CancellationToken ct)
{
input = LibraryExperiencePolicy.Normalize(input);
var now = DateTimeOffset.UtcNow;
var collection = new GameGroup(Guid.NewGuid(), input.Name, input.Description, input.Kind, input.Rule,
input.Pinned, now, now);
var audit = Audit(actor, "GameCollectionCreated", "GameCollection", collection.Id, null, collection, now);
try
{
await ExecuteTransactionAsync(ct,
("INSERT INTO game_collections(id,name,kind,version,data,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7)", [collection.Id, collection.Name, (int)collection.Kind, collection.Version, Json(collection), now, now]),
("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), now]));
}
catch (PostgresException exception) when (exception.SqlState == PostgresErrorCodes.UniqueViolation)
{
throw new ResourceConflictException("A collection with this name already exists.");
}
return collection;
}
public async Task<GameGroup> UpdateCollectionAsync(Guid id, GameCollectionInput input, long expectedVersion, string actor, CancellationToken ct)
{
input = LibraryExperiencePolicy.Normalize(input);
var before = await SingleJsonAsync<GameGroup>("SELECT data FROM game_collections WHERE id=$1", id, ct) ?? throw new KeyNotFoundException("Collection not found.");
if (before.Version != expectedVersion) throw new ConcurrencyException("The collection changed after it was loaded.");
var after = before with { Name = input.Name, Description = input.Description, Kind = input.Kind, Rule = input.Rule, Pinned = input.Pinned, UpdatedAt = DateTimeOffset.UtcNow, Version = before.Version + 1, GameCount = 0 };
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
try
{
await using var update = new NpgsqlCommand("UPDATE game_collections SET name=$2,kind=$3,version=$4,data=$5,updated_at=$6 WHERE id=$1 AND version=$7", connection, transaction);
Add(update, id, after.Name, (int)after.Kind, after.Version, Json(after), after.UpdatedAt, expectedVersion);
if (await update.ExecuteNonQueryAsync(ct) != 1) throw new ConcurrencyException("The collection changed while it was being saved.");
if (after.Kind != CollectionKind.Static)
{
await using var clear = new NpgsqlCommand("DELETE FROM game_collection_members WHERE collection_id=$1", connection, transaction);
Add(clear, id); await clear.ExecuteNonQueryAsync(ct);
}
var audit = Audit(actor, "GameCollectionUpdated", "GameCollection", id, before, after, after.UpdatedAt);
await using var auditCommand = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction);
Add(auditCommand, audit.Id, Json(audit), audit.CreatedAt); await auditCommand.ExecuteNonQueryAsync(ct);
await transaction.CommitAsync(ct);
}
catch (PostgresException exception) when (exception.SqlState == PostgresErrorCodes.UniqueViolation)
{
throw new ResourceConflictException("A collection with this name already exists.");
}
return after with { GameCount = await CountCollectionGamesAsync(after, ct) };
}
public async Task DeleteCollectionAsync(Guid id, long expectedVersion, string actor, CancellationToken ct)
{
var before = await SingleJsonAsync<GameGroup>("SELECT data FROM game_collections WHERE id=$1", id, ct) ?? throw new KeyNotFoundException("Collection not found.");
if (before.Version != expectedVersion) throw new ConcurrencyException("The collection changed after it was loaded.");
var audit = Audit(actor, "GameCollectionDeleted", "GameCollection", id, before, null, DateTimeOffset.UtcNow);
await ExecuteTransactionAsync(ct,
("DELETE FROM game_collection_members WHERE collection_id=$1", [id]),
("DELETE FROM game_collections WHERE id=$1 AND version=$2", [id, expectedVersion]),
("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt]));
}
public async Task AddCollectionGameAsync(Guid collectionId, Guid gameId, string actor, CancellationToken ct)
{
var collection = await GetCollectionAsync(collectionId, ct) ?? throw new KeyNotFoundException("Collection not found.");
if (collection.Kind != CollectionKind.Static) throw new ResourceConflictException("Smart and virtual collections are populated by their rules.");
await RequireGameAsync(gameId, ct);
var now = DateTimeOffset.UtcNow;
var audit = Audit(actor, "CollectionGameAdded", "GameCollection", collectionId, null, new { gameId }, now);
await ExecuteTransactionAsync(ct,
("INSERT INTO game_collection_members(collection_id,game_id,added_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING", [collectionId, gameId, now]),
("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), now]));
}
public async Task RemoveCollectionGameAsync(Guid collectionId, Guid gameId, string actor, CancellationToken ct)
{
var collection = await GetCollectionAsync(collectionId, ct) ?? throw new KeyNotFoundException("Collection not found.");
if (collection.Kind != CollectionKind.Static) throw new ResourceConflictException("Smart and virtual collections are populated by their rules.");
var now = DateTimeOffset.UtcNow;
var audit = Audit(actor, "CollectionGameRemoved", "GameCollection", collectionId, new { gameId }, null, now);
await ExecuteTransactionAsync(ct,
("DELETE FROM game_collection_members WHERE collection_id=$1 AND game_id=$2", [collectionId, gameId]),
("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), now]));
}
public async Task<Page<Game>> SearchCollectionGamesAsync(Guid collectionId, int page, int pageSize, CancellationToken ct)
{
var collection = await GetCollectionAsync(collectionId, ct) ?? throw new KeyNotFoundException("Collection not found.");
page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 200);
var (where, values) = CollectionFilter(collection);
await using var count = dataSource.CreateCommand("SELECT count(*) FROM games g" + where); Add(count, values);
var total = Convert.ToInt64(await count.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
await using var command = dataSource.CreateCommand("SELECT g.data FROM games g" + where + $" ORDER BY lower(g.title),g.id LIMIT {pageSize} OFFSET {(page - 1) * pageSize}"); Add(command, values);
return new(await ReadAsync<Game>(command, ct), page, pageSize, total);
}
private async Task<long> CountCollectionGamesAsync(GameGroup collection, CancellationToken ct)
{
var (where, values) = CollectionFilter(collection);
await using var command = dataSource.CreateCommand("SELECT count(*) FROM games g" + where); Add(command, values);
return Convert.ToInt64(await command.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
}
private static (string Sql, object[] Values) CollectionFilter(GameGroup collection)
{
var predicates = new List<string> { CatalogVisibility.SqlPredicate("g") };
var values = new List<object>();
void AddValue(object value, string predicate) { values.Add(value); predicates.Add(string.Format(CultureInfo.InvariantCulture, predicate, values.Count)); }
if (collection.Kind == CollectionKind.Static)
AddValue(collection.Id, "EXISTS (SELECT 1 FROM game_collection_members cm WHERE cm.collection_id=${0} AND cm.game_id=g.id)");
else
{
var rule = collection.Rule ?? new CollectionRule();
if (!string.IsNullOrWhiteSpace(rule.Query)) AddValue($"%{rule.Query.Trim()}%", "(g.title ILIKE ${0} OR EXISTS (SELECT 1 FROM metadata_claims mc WHERE mc.entity_id=g.id AND mc.value ILIKE ${0}))");
if (!string.IsNullOrWhiteSpace(rule.Platform)) AddValue(rule.Platform.Trim(), "EXISTS (SELECT 1 FROM releases r WHERE r.game_id=g.id AND r.data->>'platform'=${0})");
foreach (var tag in rule.Tags ?? []) AddValue(tag, "EXISTS (SELECT 1 FROM game_tags gt WHERE gt.game_id=g.id AND gt.normalized_name=${0})");
if (rule.Status is not null) AddValue((int)rule.Status.Value, "coalesce((SELECT gus.status FROM game_user_states gus WHERE gus.game_id=g.id),0)=${0}");
if (rule.Favorite is not null) AddValue(rule.Favorite.Value, "coalesce((SELECT gus.favorite FROM game_user_states gus WHERE gus.game_id=g.id),false)=${0}");
if (!string.IsNullOrWhiteSpace(rule.MetadataField))
{
values.Add(rule.MetadataField.Trim());
var predicate = $"EXISTS (SELECT 1 FROM metadata_claims mc WHERE mc.entity_id=g.id AND mc.field=${values.Count}";
if (!string.IsNullOrWhiteSpace(rule.MetadataValue))
{
values.Add($"%{rule.MetadataValue.Trim()}%");
predicate += $" AND mc.value ILIKE ${values.Count}";
}
predicates.Add(predicate + ")");
}
}
return (" WHERE " + string.Join(" AND ", predicates), [.. values]);
}
public async Task<IReadOnlyList<TagSummary>> ListTagsAsync(CancellationToken ct)
{
await using var command = dataSource.CreateCommand("SELECT min(data->>'name'),normalized_name,count(DISTINCT game_id) FROM game_tags GROUP BY normalized_name ORDER BY normalized_name");
var result = new List<TagSummary>();
await using var reader = await command.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct)) result.Add(new(reader.GetString(0), reader.GetString(1), reader.GetInt64(2)));
return result;
}
public Task<IReadOnlyList<GameTag>> ListGameTagsAsync(Guid gameId, CancellationToken ct) =>
ListJsonAsync<GameTag>("SELECT data FROM game_tags WHERE game_id=$1 ORDER BY normalized_name,created_at", ct, gameId);
public async Task<GameTag> AddGameTagAsync(Guid gameId, string name, string actor, CancellationToken ct)
{
await RequireGameAsync(gameId, ct);
var normalized = LibraryExperiencePolicy.NormalizeTag(name);
var existing = await SingleJsonAsync<GameTag>("SELECT data FROM game_tags WHERE game_id=$1 AND normalized_name=$2 AND data->>'sourceType'='Manual' LIMIT 1", ct, gameId, normalized);
if (existing is not null) return existing;
var tag = new GameTag(Guid.NewGuid(), gameId, name.Trim(), normalized, "Manual", actor,
Confidence.Deterministic, true, DateTimeOffset.UtcNow);
var audit = Audit(actor, "GameTagAdded", "Game", gameId, null, tag, tag.CreatedAt);
await ExecuteTransactionAsync(ct,
("INSERT INTO game_tags(id,game_id,normalized_name,data,created_at) VALUES($1,$2,$3,$4,$5)", [tag.Id, gameId, normalized, Json(tag), tag.CreatedAt]),
("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt]));
return tag;
}
public async Task DeleteGameTagAsync(Guid gameId, Guid tagId, string actor, CancellationToken ct)
{
var before = await SingleJsonAsync<GameTag>("SELECT data FROM game_tags WHERE id=$1 AND game_id=$2", ct, tagId, gameId) ?? throw new KeyNotFoundException("Tag not found.");
if (!before.ManualLock) throw new ResourceConflictException("Provider tags remain as provenance and cannot be deleted manually.");
var audit = Audit(actor, "GameTagDeleted", "Game", gameId, before, null, DateTimeOffset.UtcNow);
await ExecuteTransactionAsync(ct,
("DELETE FROM game_tags WHERE id=$1 AND game_id=$2", [tagId, gameId]),
("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt]));
}
public async Task<GameUserState> GetGameUserStateAsync(Guid gameId, CancellationToken ct)
{
await RequireGameAsync(gameId, ct);
return await SingleJsonAsync<GameUserState>("SELECT data FROM game_user_states WHERE game_id=$1", gameId, ct) ?? GameUserState.Empty(gameId);
}
public async Task<GameUserState> SaveGameUserStateAsync(Guid gameId, GameUserStateInput input, long expectedVersion, string actor, CancellationToken ct)
{
await RequireGameAsync(gameId, ct);
input = LibraryExperiencePolicy.Normalize(input);
var before = await SingleJsonAsync<GameUserState>("SELECT data FROM game_user_states WHERE game_id=$1", gameId, ct);
var currentVersion = before?.Version ?? 0;
if (currentVersion != expectedVersion) throw new ConcurrencyException("Personal game state changed after it was loaded.");
var now = DateTimeOffset.UtcNow;
var after = new GameUserState(gameId, input.Favorite, input.Status, input.Rating, input.Difficulty,
input.CompletionPercent, input.Notes, input.PlayCount, input.LastPlayedAt, now, currentVersion + 1);
var audit = Audit(actor, "GameUserStateSaved", "Game", gameId, before, after, now);
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
await using var command = before is null
? new NpgsqlCommand("INSERT INTO game_user_states(game_id,status,favorite,version,data,updated_at) VALUES($1,$2,$3,$4,$5,$6)", connection, transaction)
: new NpgsqlCommand("UPDATE game_user_states SET status=$2,favorite=$3,version=$4,data=$5,updated_at=$6 WHERE game_id=$1 AND version=$7", connection, transaction);
Add(command, gameId, (int)after.Status, after.Favorite, after.Version, Json(after), now);
if (before is not null) command.Parameters.AddWithValue(expectedVersion);
if (await command.ExecuteNonQueryAsync(ct) != 1) throw new ConcurrencyException("Personal game state changed while it was being saved.");
await using var auditCommand = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction);
Add(auditCommand, audit.Id, Json(audit), now); await auditCommand.ExecuteNonQueryAsync(ct);
await transaction.CommitAsync(ct); return after;
}
public async Task<GameUserState> RecordGamePlayedAsync(Guid gameId, string actor, CancellationToken ct)
{
await RequireGameAsync(gameId, ct);
var now = DateTimeOffset.UtcNow;
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
await using (var gameLock = new NpgsqlCommand("SELECT pg_advisory_xact_lock(hashtextextended(($1)::text,0))", connection, transaction))
{
gameLock.Parameters.AddWithValue(gameId);
await gameLock.ExecuteNonQueryAsync(ct);
}
GameUserState? before;
await using (var read = new NpgsqlCommand("SELECT data FROM game_user_states WHERE game_id=$1 FOR UPDATE", connection, transaction))
{
read.Parameters.AddWithValue(gameId);
var value = await read.ExecuteScalarAsync(ct);
before = value is null or DBNull ? null : System.Text.Json.JsonSerializer.Deserialize<GameUserState>((string)value, JsonOptions);
}
var current = before ?? GameUserState.Empty(gameId);
var after = current with
{
PlayCount = current.PlayCount == int.MaxValue ? int.MaxValue : current.PlayCount + 1,
LastPlayedAt = now,
UpdatedAt = now,
Version = current.Version + 1,
};
await using (var save = before is null
? new NpgsqlCommand("INSERT INTO game_user_states(game_id,status,favorite,version,data,updated_at) VALUES($1,$2,$3,$4,$5,$6)", connection, transaction)
: new NpgsqlCommand("UPDATE game_user_states SET status=$2,favorite=$3,version=$4,data=$5,updated_at=$6 WHERE game_id=$1", connection, transaction))
{
Add(save, gameId, (int)after.Status, after.Favorite, after.Version, Json(after), now);
await save.ExecuteNonQueryAsync(ct);
}
var audit = Audit(actor, "GamePlayed", "Game", gameId, before, after, now);
await using var auditCommand = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction);
Add(auditCommand, audit.Id, Json(audit), now);
await auditCommand.ExecuteNonQueryAsync(ct);
await transaction.CommitAsync(ct);
return after;
}
public Task<IReadOnlyList<RecentlyPlayedGame>> ListRecentlyPlayedGamesAsync(int limit, CancellationToken ct)
{
limit = Math.Clamp(limit, 1, 12);
return ListJsonAsync<RecentlyPlayedGame>($"""
SELECT jsonb_build_object('game',g.data,'state',gus.data)
FROM game_user_states gus
JOIN games g ON g.id=gus.game_id
WHERE gus.data->>'lastPlayedAt' IS NOT NULL AND {CatalogVisibility.SqlPredicate()}
ORDER BY (gus.data->>'lastPlayedAt')::timestamptz DESC,g.title
LIMIT {limit}
""", ct);
}
public Task<IReadOnlyList<GameRelation>> ListGameRelationsAsync(Guid gameId, CancellationToken ct) =>
ListJsonAsync<GameRelation>("SELECT data FROM game_relations WHERE game_id=$1 ORDER BY kind,created_at", ct, gameId);
public async Task<GameRelation> AddGameRelationAsync(Guid gameId, GameRelationInput input, string actor, CancellationToken ct)
{
await RequireGameAsync(gameId, ct); input = LibraryExperiencePolicy.Normalize(input);
if (input.RelatedGameId is not null) await RequireGameAsync(input.RelatedGameId.Value, ct);
if (input.RelatedGameId == gameId) throw new ArgumentException("A game cannot relate to itself.");
if (input.ArtifactId is not null && await SingleJsonAsync<Artifact>("SELECT data FROM artifacts WHERE id=$1", input.ArtifactId.Value, ct) is null) throw new KeyNotFoundException("Artifact not found.");
var relation = new GameRelation(Guid.NewGuid(), gameId, input.Kind, input.Title, input.RelatedGameId,
input.ArtifactId, "Manual", actor, Confidence.Deterministic, true, DateTimeOffset.UtcNow);
var audit = Audit(actor, "GameRelationAdded", "Game", gameId, null, relation, relation.CreatedAt);
await ExecuteTransactionAsync(ct,
("INSERT INTO game_relations(id,game_id,kind,related_game_id,artifact_id,data,created_at) VALUES($1,$2,$3,$4,$5,$6,$7)", [relation.Id, gameId, (int)relation.Kind, DbValue(relation.RelatedGameId), DbValue(relation.ArtifactId), Json(relation), relation.CreatedAt]),
("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt]));
return relation;
}
public async Task DeleteGameRelationAsync(Guid gameId, Guid relationId, string actor, CancellationToken ct)
{
var before = await SingleJsonAsync<GameRelation>("SELECT data FROM game_relations WHERE id=$1 AND game_id=$2", ct, relationId, gameId) ?? throw new KeyNotFoundException("Relationship not found.");
if (!before.ManualLock) throw new ResourceConflictException("Provider relationships remain as provenance and cannot be deleted manually.");
var audit = Audit(actor, "GameRelationDeleted", "Game", gameId, before, null, DateTimeOffset.UtcNow);
await ExecuteTransactionAsync(ct,
("DELETE FROM game_relations WHERE id=$1 AND game_id=$2", [relationId, gameId]),
("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt]));
}
public Task<IReadOnlyList<GameMedia>> ListGameMediaAsync(Guid gameId, CancellationToken ct) =>
ListJsonAsync<GameMedia>("SELECT data FROM game_media WHERE game_id=$1 ORDER BY kind,selected DESC,created_at", ct, gameId);
public async Task<GameMedia> AddExternalGameMediaAsync(Guid gameId, GameMediaInput input, string actor, CancellationToken ct)
{
await RequireGameAsync(gameId, ct); input = LibraryExperiencePolicy.Normalize(input);
if (input.Location is null) throw new ArgumentException("An external media location is required.");
var selected = await SingleJsonAsync<GameMedia>("SELECT data FROM game_media WHERE game_id=$1 AND kind=$2 AND selected LIMIT 1", ct, gameId, (int)input.Kind) is null;
var media = new GameMedia(Guid.NewGuid(), gameId, input.Kind, input.Title, "External",
input.Provider, input.ExternalId, input.Location, null, null, null, false, selected, DateTimeOffset.UtcNow);
await UpsertGameMediaAsync(media, ct);
var audit = Audit(actor, "ExternalGameMediaAdded", "Game", gameId, null, media, media.CreatedAt);
await ExecuteAsync("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", ct, audit.Id, Json(audit), audit.CreatedAt);
return media;
}
public async Task UpsertGameMediaAsync(GameMedia media, CancellationToken ct)
{
await RequireGameAsync(media.GameId, ct);
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct);
if (media.Selected)
{
await using var deselect = new NpgsqlCommand("UPDATE game_media SET selected=false,data=jsonb_set(data,'{selected}','false'::jsonb),updated_at=now() WHERE game_id=$1 AND kind=$2 AND id<>$3 AND selected", connection, transaction);
Add(deselect, media.GameId, (int)media.Kind, media.Id); await deselect.ExecuteNonQueryAsync(ct);
}
await using var upsert = new NpgsqlCommand("INSERT INTO game_media(id,game_id,kind,app_owned,selected,data,created_at) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(id) DO UPDATE SET selected=excluded.selected,data=excluded.data,updated_at=now()", connection, transaction);
Add(upsert, media.Id, media.GameId, (int)media.Kind, media.AppOwned, media.Selected, Json(media), media.CreatedAt);
await upsert.ExecuteNonQueryAsync(ct); await transaction.CommitAsync(ct);
}
public Task<GameMedia?> GetGameMediaAsync(Guid mediaId, CancellationToken ct) =>
SingleJsonAsync<GameMedia>("SELECT data FROM game_media WHERE id=$1", mediaId, ct);
public async Task DeleteGameMediaAsync(Guid gameId, Guid mediaId, string actor, CancellationToken ct)
{
var before = await SingleJsonAsync<GameMedia>("SELECT data FROM game_media WHERE id=$1 AND game_id=$2", ct, mediaId, gameId) ?? throw new KeyNotFoundException("Media not found.");
var audit = Audit(actor, "GameMediaDeleted", "Game", gameId, before, null, DateTimeOffset.UtcNow);
await ExecuteTransactionAsync(ct,
("DELETE FROM game_media WHERE id=$1 AND game_id=$2", [mediaId, gameId]),
("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt]));
}
public Task<GameAchievementProgress?> GetAchievementProgressAsync(Guid gameId, CancellationToken ct) =>
SingleJsonAsync<GameAchievementProgress>("SELECT data FROM game_achievement_progress WHERE game_id=$1", gameId, ct);
public async Task UpsertAchievementProgressAsync(GameAchievementProgress progress, CancellationToken ct) =>
await ExecuteAsync("INSERT INTO game_achievement_progress(game_id,provider,data,updated_at) VALUES($1,$2,$3,$4) ON CONFLICT(game_id) DO UPDATE SET provider=excluded.provider,data=excluded.data,updated_at=excluded.updated_at", ct,
progress.GameId, progress.Provider, Json(progress), progress.UpdatedAt);
public Task<IReadOnlyList<PlatformDefinition>> ListPlatformDefinitionsAsync(CancellationToken ct) =>
ListJsonAsync<PlatformDefinition>("SELECT data FROM platform_definitions ORDER BY custom DESC,lower(data->>'category'),lower(data->>'name')", ct);
public async Task<PlatformDefinition> CreatePlatformDefinitionAsync(PlatformDefinitionInput input, string actor, CancellationToken ct)
{
input = LibraryExperiencePolicy.Normalize(input);
var definition = new PlatformDefinition(input.Id, input.Name, input.Category, input.Aliases,
true, true, input.Enabled, DateTimeOffset.UtcNow);
var audit = Audit(actor, "CustomPlatformCreated", "PlatformDefinition", Guid.Empty, null, definition, definition.UpdatedAt);
try
{
await ExecuteTransactionAsync(ct,
("INSERT INTO platform_definitions(id,custom,enabled,version,data,updated_at) VALUES($1,true,$2,1,$3,$4)", [definition.Id, definition.Enabled, Json(definition), definition.UpdatedAt]),
("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", [audit.Id, Json(audit), audit.CreatedAt]));
}
catch (PostgresException exception) when (exception.SqlState == PostgresErrorCodes.UniqueViolation)
{
throw new ResourceConflictException("A platform with this id already exists.");
}
return definition;
}
public async Task<PlatformDefinition> UpdatePlatformDefinitionAsync(string id, PlatformDefinitionInput input, long expectedVersion, string actor, CancellationToken ct)
{
input = LibraryExperiencePolicy.Normalize(input);
if (!string.Equals(id, input.Id, StringComparison.Ordinal)) throw new ArgumentException("A platform id cannot be changed.");
var before = await SinglePlatformAsync(id, ct) ?? throw new KeyNotFoundException("Platform not found.");
if (!before.Custom) throw new ResourceConflictException("Built-in platform definitions are immutable; create a custom alias instead.");
if (before.Version != expectedVersion) throw new ConcurrencyException("The platform changed after it was loaded.");
var after = before with { Name = input.Name, Category = input.Category, Aliases = input.Aliases, Enabled = input.Enabled, UpdatedAt = DateTimeOffset.UtcNow, Version = before.Version + 1 };
var audit = Audit(actor, "CustomPlatformUpdated", "PlatformDefinition", Guid.Empty, before, after, after.UpdatedAt);
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
await using var command = new NpgsqlCommand("UPDATE platform_definitions SET enabled=$2,version=$3,data=$4,updated_at=$5 WHERE id=$1 AND version=$6", connection, transaction);
Add(command, id, after.Enabled, after.Version, Json(after), after.UpdatedAt, expectedVersion);
if (await command.ExecuteNonQueryAsync(ct) != 1) throw new ConcurrencyException("The platform changed while it was being saved.");
await using var auditCommand = new NpgsqlCommand("INSERT INTO audit_events(id,data,created_at) VALUES($1,$2,$3)", connection, transaction);
Add(auditCommand, audit.Id, Json(audit), audit.CreatedAt);
await auditCommand.ExecuteNonQueryAsync(ct);
await transaction.CommitAsync(ct);
return after;
}
private async Task<PlatformDefinition?> SinglePlatformAsync(string id, CancellationToken ct)
{
await using var command = dataSource.CreateCommand("SELECT data FROM platform_definitions WHERE id=$1"); Add(command, id);
var value = await command.ExecuteScalarAsync(ct);
return value is null or DBNull ? null : Deserialize<PlatformDefinition>((string)value);
}
private async Task RequireGameAsync(Guid gameId, CancellationToken ct)
{
if (await SingleJsonAsync<Game>("SELECT data FROM games WHERE id=$1", gameId, ct) is null)
throw new KeyNotFoundException("Game not found.");
}
private static AuditEvent Audit(string actor, string action, string entityType,
Guid entityId, object? before, object? after, DateTimeOffset at) =>
new(Guid.NewGuid(), actor, action, entityType, entityId,
before is null ? null : System.Text.Json.JsonSerializer.Serialize(before, before.GetType(), JsonOptions),
after is null ? null : System.Text.Json.JsonSerializer.Serialize(after, after.GetType(), JsonOptions),
Guid.NewGuid(), at);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
using System.Text.Json;
using Ludarium.Application;
namespace Ludarium.Infrastructure;
public sealed class RawgGameDiscoveryService(HttpClient client, string? apiKey) : IGameDiscoveryService
{
private const int MaximumResponseBytes = 2 * 1024 * 1024;
public bool Configured => !string.IsNullOrWhiteSpace(apiKey);
public async Task<GameDiscoveryResponse> SearchAsync(string query, CancellationToken ct)
{
if (!Configured) return new(false, "RAWG", [], "Set RAWG_API_KEY to enable optional online discovery.");
if (string.IsNullOrWhiteSpace(query) || query.Trim().Length < 2) return new(true, "RAWG", []);
using var response = await client.GetAsync($"https://api.rawg.io/api/games?key={Uri.EscapeDataString(apiKey!)}&search={Uri.EscapeDataString(query.Trim())}&search_exact=true&page_size=8", HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength is > MaximumResponseBytes) throw new InvalidDataException("RAWG response exceeds the 2 MiB safety limit.");
await using var stream = await response.Content.ReadAsStreamAsync(ct);
await using var bounded = new MemoryStream();
var buffer = new byte[64 * 1024];
while (true)
{
var read = await stream.ReadAsync(buffer, ct);
if (read == 0) break;
if (bounded.Length + read > MaximumResponseBytes) throw new InvalidDataException("RAWG response exceeds the 2 MiB safety limit.");
await bounded.WriteAsync(buffer.AsMemory(0, read), ct);
}
bounded.Position = 0;
using var json = await JsonDocument.ParseAsync(bounded, cancellationToken: ct);
var results = new List<GameDiscoveryResult>();
foreach (var game in json.RootElement.GetProperty("results").EnumerateArray())
{
var platform = game.TryGetProperty("platforms", out var ps) && ps.GetArrayLength() > 0 ? NormalizePlatform(ps[0].GetProperty("platform").GetProperty("slug").GetString()) : null;
DateTimeOffset? released = game.TryGetProperty("released", out var rd) && DateTimeOffset.TryParse(rd.GetString(), out var date) ? date : null;
results.Add(new("RAWG", game.GetProperty("id").ToString(), game.GetProperty("name").GetString() ?? "Unknown game", platform, released,
game.TryGetProperty("background_image", out var image) && image.ValueKind == JsonValueKind.String ? image.GetString() : null,
game.TryGetProperty("slug", out var slug) ? $"https://rawg.io/games/{slug.GetString()}" : null));
}
return new(true, "RAWG", results);
}
private static string? NormalizePlatform(string? value) => value switch
{
"pc" => "windows",
"playstation" => "psx",
"playstation2" => "ps2",
"playstation3" => "ps3",
"playstation4" => "ps4",
"playstation5" => "ps5",
"psp" => "psp",
"ps-vita" => "psvita",
"nes" => "nes",
"snes" => "snes",
"nintendo-64" => "n64",
"game-boy" => "gb",
"game-boy-color" => "gbc",
"game-boy-advance" => "gba",
"nintendo-ds" => "nds",
"nintendo-3ds" => "3ds",
"gamecube" => "gamecube",
"wii" => "wii",
"nintendo-switch" => "switch",
_ => null
};
}
@@ -0,0 +1,130 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.Infrastructure;
public sealed partial class ReadOnlyLibraryFileSystem : IReadOnlyLibraryFileSystem
{
public RootVerification Verify(string path)
{
try
{
var full = Path.GetFullPath(path);
if (!Directory.Exists(full)) return new(false, false, false, "The configured root does not exist.");
using var enumerator = Directory.EnumerateFileSystemEntries(full).GetEnumerator();
var hasEntries = enumerator.MoveNext();
// Unix mount flags are the authoritative deployment check. On Windows a non-destructive probe cannot prove mount mode.
var readOnly = !OperatingSystem.IsWindows() && IsUnixReadOnly(full);
return new(true, true, readOnly, readOnly ? "Root is readable and mounted read-only." : "Root is readable; read-only mount could not be confirmed.", hasEntries);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return new(Directory.Exists(path), false, false, $"Root is not readable: {ex.Message}");
}
}
public async IAsyncEnumerable<FileObservation> EnumerateAsync(LibraryRoot root,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var options = new EnumerationOptions
{
RecurseSubdirectories = root.Recursive,
IgnoreInaccessible = false,
ReturnSpecialDirectories = false,
AttributesToSkip = FileAttributes.ReparsePoint
};
foreach (var path in Directory.EnumerateFiles(root.Path, "*", options))
{
cancellationToken.ThrowIfCancellationRequested();
var relative = Path.GetRelativePath(root.Path, path).Replace('\\', '/');
if (root.Exclusions?.Any(x => GlobMatch(relative, x)) == true) continue;
var info = new FileInfo(path);
yield return new(relative, info.Length, info.LastWriteTimeUtc);
await Task.Yield();
}
}
public ValueTask<Stream> OpenReadAsync(LibraryRoot root, string relativePath, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var path = ResolveContainedPath(root, relativePath);
Stream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete,
1024 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
return ValueTask.FromResult(stream);
}
public string ResolveContainedPath(LibraryRoot root, string relativePath)
{
if (Path.IsPathFullyQualified(relativePath)) throw new InvalidOperationException("Only relative library paths are allowed.");
var rootPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root.Path));
var candidate = Path.GetFullPath(Path.Combine(rootPath, relativePath));
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
if (!candidate.StartsWith(rootPath + Path.DirectorySeparatorChar, comparison) && !candidate.Equals(rootPath, comparison))
throw new InvalidOperationException("Path escapes the configured library root.");
RejectLinkedPathComponents(rootPath, candidate);
return candidate;
}
private static void RejectLinkedPathComponents(string rootPath, string candidate)
{
var relative = Path.GetRelativePath(rootPath, candidate);
if (relative == ".") return;
var current = rootPath;
foreach (var component in relative.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
StringSplitOptions.RemoveEmptyEntries))
{
current = Path.Combine(current, component);
if (!File.Exists(current) && !Directory.Exists(current)) continue;
var entry = Directory.Exists(current) ? (FileSystemInfo)new DirectoryInfo(current) : new FileInfo(current);
if (entry.LinkTarget is not null || entry.Attributes.HasFlag(FileAttributes.ReparsePoint))
throw new InvalidOperationException("Linked paths are not allowed inside a library root.");
}
}
private static bool GlobMatch(string value, string pattern)
{
var normalizedValue = value.Replace('\\', '/');
var normalizedPattern = pattern.Trim().Replace('\\', '/').TrimStart('/');
if (normalizedPattern.Length == 0) return false;
if (!normalizedPattern.Contains('/') && !normalizedPattern.Contains('*') && !normalizedPattern.Contains('?'))
return Path.GetFileName(normalizedValue).Equals(normalizedPattern, StringComparison.OrdinalIgnoreCase);
var expression = System.Text.RegularExpressions.Regex.Escape(normalizedPattern)
.Replace("\\*\\*", ".*", StringComparison.Ordinal)
.Replace("\\*", "[^/]*", StringComparison.Ordinal)
.Replace("\\?", "[^/]", StringComparison.Ordinal);
return System.Text.RegularExpressions.Regex.IsMatch(normalizedValue, $"^{expression}$",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant,
TimeSpan.FromMilliseconds(100));
}
private static bool IsUnixReadOnly(string path)
{
const string mountInfo = "/proc/self/mountinfo";
if (!File.Exists(mountInfo)) return false;
var fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path));
string? bestMount = null;
var bestReadOnly = false;
foreach (var line in File.ReadLines(mountInfo))
{
var fields = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (fields.Length < 6) continue;
var mountPoint = DecodeMountInfoPath(fields[4]);
if (!fullPath.Equals(mountPoint, StringComparison.Ordinal) &&
!fullPath.StartsWith(Path.TrimEndingDirectorySeparator(mountPoint) + Path.DirectorySeparatorChar, StringComparison.Ordinal))
continue;
if (bestMount is not null && mountPoint.Length <= bestMount.Length) continue;
bestMount = mountPoint;
bestReadOnly = fields[5].Split(',').Contains("ro", StringComparer.Ordinal);
}
return bestMount is not null && bestReadOnly;
}
private static string DecodeMountInfoPath(string value) => value
.Replace("\\040", " ", StringComparison.Ordinal)
.Replace("\\011", "\t", StringComparison.Ordinal)
.Replace("\\012", "\n", StringComparison.Ordinal)
.Replace("\\134", "\\", StringComparison.Ordinal);
}
@@ -0,0 +1,38 @@
using System.IO.Compression;
using System.Text.Json;
using Ludarium.Application;
namespace Ludarium.Infrastructure;
public sealed class SupportBundleService(ILudariumStore store, string exportRoot) : ISupportBundleService
{
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { WriteIndented = true };
public async Task<SupportBundleResult> CreateAsync(CancellationToken cancellationToken)
{
Directory.CreateDirectory(exportRoot);
var createdAt = DateTimeOffset.UtcNow;
var fileName = $"ludarium-support-{createdAt:yyyyMMdd-HHmmss}.zip";
var target = Path.Combine(Path.GetFullPath(exportRoot), fileName);
var libraries = await store.ListLibrariesAsync(cancellationToken);
var scans = await store.ListScansAsync(cancellationToken);
var findings = await store.ListFindingsAsync(cancellationToken);
await using var output = new FileStream(target, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, FileOptions.Asynchronous);
using var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true);
await WriteJsonAsync(archive, "system.json", new { applicationVersion = ReleaseIdentity.Version, schemaVersion = PostgresStore.CurrentSchemaVersion, createdAt, telemetry = false, externalProvidersRequired = false }, cancellationToken);
await WriteJsonAsync(archive, "libraries.json", libraries.Select(x => new { x.Id, x.Name, x.Kind, x.Enabled, x.IsAvailable, x.IsReadOnly, x.LastVerifiedAt, path = "[redacted]" }), cancellationToken);
await WriteJsonAsync(archive, "scans.json", scans.Select(x => new { x.Id, x.LibraryId, x.Mode, x.State, x.Stage, x.ItemsCompleted, x.BytesCompleted, x.CreatedAt, x.StartedAt, x.FinishedAt, error = x.Error is null ? null : "[redacted diagnostic; consult local logs]" }), cancellationToken);
// Finding text can contain source paths or analyzer/provider diagnostics. Export only the
// operational classification; the local authenticated UI remains the place for details.
await WriteJsonAsync(archive, "findings.json", findings.Select(x => new { x.Id, x.Category, x.Severity, message = "[redacted finding detail; consult the local authenticated UI]", x.CreatedAt }), cancellationToken);
await WriteJsonAsync(archive, "capabilities.json", new { peHeaders = true, peVersionResources = true, msiSummaryInformation = true, msiDatabaseProperties = true, msiMediaAndFileRelations = true, msiCabinetExtraction = false, zipCentralDirectory = true, sevenZipInspection = true, logiqx = true, cue = true, m3u = true, gameDataVault = true, sourceMutation = false, executableLaunch = false }, cancellationToken);
return new(fileName, createdAt, libraries.Count, scans.Count);
}
private static async Task WriteJsonAsync<T>(ZipArchive archive, string name, T value, CancellationToken cancellationToken)
{
var entry = archive.CreateEntry(name, CompressionLevel.SmallestSize);
await using var stream = entry.Open();
await JsonSerializer.SerializeAsync(stream, value, Options, cancellationToken);
}
}
@@ -0,0 +1,73 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.Infrastructure;
/// <summary>Copies only verified, operator-uploaded Switch keys into app-owned Eden configuration.</summary>
public sealed class SwitchRuntimeProvisioner(string? configuredRoot)
{
private readonly string? root = NormalizeRoot(configuredRoot);
public bool Configured => root is not null;
public async Task ProvisionAsync(FirmwareAsset asset, FirmwareVaultFileStore vault, CancellationToken ct)
{
var fileName = RuntimeFileName(asset.Platform, asset.Slot);
if (root is null || fileName is null) return;
var content = await vault.OpenAsync(asset, ct)
?? throw new FirmwareIntegrityException("The selected Switch key file is missing from the firmware vault.");
await using var input = content.Content;
Directory.CreateDirectory(root);
var destination = Path.Combine(root, fileName);
var temporary = Path.Combine(root, $".{fileName}.{Guid.NewGuid():N}.uploading");
try
{
await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write,
FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan))
{
RestrictToOwner(temporary);
await input.CopyToAsync(output, ct);
await output.FlushAsync(ct);
}
File.Move(temporary, destination, true);
RestrictToOwner(destination);
}
finally
{
if (File.Exists(temporary)) File.Delete(temporary);
}
}
public void Remove(FirmwareAsset asset)
{
var fileName = RuntimeFileName(asset.Platform, asset.Slot);
if (root is null || fileName is null) return;
var path = Path.Combine(root, fileName);
if (File.Exists(path)) File.Delete(path);
}
private static string? RuntimeFileName(string platform, string slot)
{
if (!platform.Equals("switch", StringComparison.OrdinalIgnoreCase)) return null;
return slot.ToLowerInvariant() switch
{
"prod-keys" => "prod.keys",
"title-keys" => "title.keys",
_ => null
};
}
private static string? NormalizeRoot(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (!Path.IsPathFullyQualified(value))
throw new InvalidOperationException("LUDARIUM_SWITCH_KEYS_ROOT must be an absolute app-owned path.");
return Path.GetFullPath(value);
}
private static void RestrictToOwner(string path)
{
if (OperatingSystem.IsWindows()) return;
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
}
@@ -0,0 +1,56 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Npgsql": {
"type": "Direct",
"requested": "[10.0.3, )",
"resolved": "10.0.3",
"contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA=="
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0"
}
},
"OpenMcdf": {
"type": "Transitive",
"resolved": "3.2.0",
"contentHash": "n/iojS7V77YjM6IBbXaP0ZI8EELhJ2j6XRjx9NxMyUtjcM4a0yr0fWqO1y7lgd3CgYwnDugaKlO9c7Os8VxNtg=="
},
"SharpCompress": {
"type": "Transitive",
"resolved": "0.50.4",
"contentHash": "/hxjUR7DEX6mky8/LQXyrnrKioOL6D6veAID1EZpro+q4s02x5dHEYBV3qjEN6lDYYilDoQQ76BcmQj+lRx51w=="
},
"System.IO.Hashing": {
"type": "Transitive",
"resolved": "10.0.11",
"contentHash": "OzKDcIRkeNJeC8qAsbn8yJXnfTLP1dtkWILe+T56Gf/z+IkAASi7sMqLqJQat08j5z/mRN5xVtoAwbkMNMoBUQ=="
},
"ludarium.application": {
"type": "Project",
"dependencies": {
"Ludarium.Domain": "[1.0.0, )",
"OpenMcdf": "[3.2.0, )",
"SharpCompress": "[0.50.4, )",
"System.IO.Hashing": "[10.0.11, )"
}
},
"ludarium.domain": {
"type": "Project"
}
}
}
}