This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user