@@ -0,0 +1,35 @@
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Infrastructure;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Ludarium.Api;
|
||||
|
||||
public sealed class ApiExceptionHandler(ILogger<ApiExceptionHandler> logger) : IExceptionHandler
|
||||
{
|
||||
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (status, title) = exception switch
|
||||
{
|
||||
ArgumentException or MediaValidationException or ArtworkValidationException or GameDataVaultValidationException or FirmwareValidationException =>
|
||||
(StatusCodes.Status400BadRequest, "The request was not accepted"),
|
||||
KeyNotFoundException => (StatusCodes.Status404NotFound, "The requested resource was not found"),
|
||||
ResourceConflictException or ConcurrencyException or SnapshotPreconditionException or GameDataIntegrityException or FirmwareIntegrityException =>
|
||||
(StatusCodes.Status409Conflict, "The request conflicts with current state"),
|
||||
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred")
|
||||
};
|
||||
if (status >= 500) logger.LogError(exception, "Unhandled API request failure");
|
||||
else if (logger.IsEnabled(LogLevel.Information))
|
||||
logger.LogInformation("API request rejected with {StatusCode}: {Message}", status, exception.Message);
|
||||
context.Response.StatusCode = status;
|
||||
await context.Response.WriteAsJsonAsync(new ProblemDetails
|
||||
{
|
||||
Status = status,
|
||||
Title = title,
|
||||
Detail = status >= 500 ? "The operation could not be completed." : exception.Message,
|
||||
Instance = context.Request.Path
|
||||
}, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Ludarium.Api;
|
||||
|
||||
public sealed record ApiRateLimitProfile(string Partition, int PermitLimit, TimeSpan Window);
|
||||
|
||||
public static class ApiRateLimits
|
||||
{
|
||||
public static ApiRateLimitProfile For(string method, PathString path)
|
||||
{
|
||||
var expensive = path.StartsWithSegments("/api/v1/games/artwork/enrich") ||
|
||||
path.StartsWithSegments("/api/v1/games/metadata/enrich") ||
|
||||
path.StartsWithSegments("/api/v1/system/cheats/refresh") ||
|
||||
path.StartsWithSegments("/api/v1/exports");
|
||||
if (expensive) return new("expensive", 12, TimeSpan.FromMinutes(1));
|
||||
return HttpMethods.IsGet(method)
|
||||
? new("read", 1200, TimeSpan.FromMinutes(1))
|
||||
: new("mutation", 120, TimeSpan.FromMinutes(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Ludarium.Application\Ludarium.Application.csproj" />
|
||||
<ProjectReference Include="..\Ludarium.Infrastructure\Ludarium.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@Ludarium.Api_HostAddress = http://localhost:5135
|
||||
|
||||
GET {{Ludarium.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,126 @@
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Infrastructure;
|
||||
|
||||
namespace Ludarium.Api;
|
||||
|
||||
/// <summary>The exact title a native player may start, or the reason it may not.</summary>
|
||||
public sealed record NativeLaunchResolution(string? RuntimePath, string? Platform,
|
||||
string? RejectTitle = null, string? RejectDetail = null, int RejectStatus = 409)
|
||||
{
|
||||
public bool Rejected => RuntimePath is null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-player rules for turning a game id into one exact runtime path.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything a native player shares — configuration, sessions, cookies, proxying, control,
|
||||
/// error mapping — lives in <see cref="NativeRemotePlayerRuntime"/>. What genuinely differs is
|
||||
/// which linked file may start and which prerequisites apply, and only that belongs here.
|
||||
/// </remarks>
|
||||
public interface INativeLaunchResolver
|
||||
{
|
||||
string Key { get; }
|
||||
|
||||
Task<NativeRemotePlayCapability> EvaluateAsync(Guid gameId, bool runtimeReady, bool automaticRestore,
|
||||
DateTimeOffset checkedAt, CancellationToken ct);
|
||||
|
||||
Task<NativeLaunchResolution> ResolveAsync(Guid gameId, NativeRemotePlayerControlClient control,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class SwitchLaunchResolver(
|
||||
ILudariumStore store,
|
||||
IBrowserPlayStore browserPlayStore,
|
||||
IFirmwareStore firmware,
|
||||
SwitchRuntimeProvisioner provisioner) : INativeLaunchResolver
|
||||
{
|
||||
public string Key => NativeRemotePlayerRegistry.Switch.Key;
|
||||
|
||||
/// <summary>The Switch runtime also needs operator-supplied keys before it is usable at all.</summary>
|
||||
public bool RuntimeProvisioned => provisioner.Configured;
|
||||
|
||||
public async Task<NativeRemotePlayCapability> EvaluateAsync(Guid gameId, bool runtimeReady,
|
||||
bool automaticRestore, DateTimeOffset checkedAt, CancellationToken ct)
|
||||
{
|
||||
var isSwitchGame = await IsSwitchGameAsync(gameId, ct);
|
||||
var candidates = isSwitchGame ? await browserPlayStore.ListSwitchLaunchCandidatesAsync(gameId, ct) : [];
|
||||
var keysReady = await ProductionKeysReadyAsync(ct);
|
||||
return SwitchLaunchPolicy.EvaluateCapability(gameId, isSwitchGame,
|
||||
runtimeReady && provisioner.Configured, keysReady, candidates, checkedAt, automaticRestore);
|
||||
}
|
||||
|
||||
public async Task<NativeLaunchResolution> ResolveAsync(Guid gameId,
|
||||
NativeRemotePlayerControlClient control, CancellationToken ct)
|
||||
{
|
||||
if (!await IsSwitchGameAsync(gameId, ct))
|
||||
return new(null, null, "Unsupported platform",
|
||||
"Only a Nintendo Switch game can start an Eden session.", StatusCodes.Status400BadRequest);
|
||||
if (!await ProductionKeysReadyAsync(ct))
|
||||
return new(null, "switch", "Switch production keys required",
|
||||
"Upload and select your own prod.keys in Settings.", StatusCodes.Status409Conflict);
|
||||
var baseGame = SwitchLaunchPolicy.SelectBaseGame(
|
||||
await browserPlayStore.ListSwitchLaunchCandidatesAsync(gameId, ct));
|
||||
if (baseGame is null)
|
||||
return new(null, "switch", "Exact Switch base game is ambiguous",
|
||||
"Ludarium could not identify one unique read-only base XCI/NSP. Review the game's linked Switch files.",
|
||||
StatusCodes.Status409Conflict);
|
||||
return new(SwitchLaunchPolicy.ToRuntimePath(baseGame.RelativePath, control.CatalogPrefix("switch")), "switch");
|
||||
}
|
||||
|
||||
private async Task<bool> IsSwitchGameAsync(Guid gameId, CancellationToken ct) =>
|
||||
(await store.ListReleasesAsync(gameId, ct))
|
||||
.Any(release => string.Equals(release.Platform, "switch", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private async Task<bool> ProductionKeysReadyAsync(CancellationToken ct) =>
|
||||
await firmware.GetSelectedFirmwareAssetAsync("switch", "prod-keys", ct) is not null;
|
||||
}
|
||||
|
||||
public sealed class DolphinLaunchResolver(ILudariumStore store, IBrowserPlayStore browserPlayStore)
|
||||
: INativeLaunchResolver
|
||||
{
|
||||
public string Key => NativeRemotePlayerRegistry.Dolphin.Key;
|
||||
|
||||
public async Task<NativeRemotePlayCapability> EvaluateAsync(Guid gameId, bool runtimeReady,
|
||||
bool automaticRestore, DateTimeOffset checkedAt, CancellationToken ct)
|
||||
{
|
||||
var platform = await ResolvePlatformAsync(gameId, ct);
|
||||
var candidates = platform is null ? []
|
||||
: await browserPlayStore.ListDolphinLaunchCandidatesAsync(gameId, platform, ct);
|
||||
return DolphinLaunchPolicy.EvaluateCapability(gameId, platform, runtimeReady, candidates,
|
||||
checkedAt, automaticRestore);
|
||||
}
|
||||
|
||||
public async Task<NativeLaunchResolution> ResolveAsync(Guid gameId,
|
||||
NativeRemotePlayerControlClient control, CancellationToken ct)
|
||||
{
|
||||
var platform = await ResolvePlatformAsync(gameId, ct);
|
||||
if (platform is null)
|
||||
return new(null, null, "Unsupported platform",
|
||||
"Only a GameCube or Wii game can start a Dolphin session.", StatusCodes.Status400BadRequest);
|
||||
var selected = DolphinLaunchPolicy.SelectGame(
|
||||
await browserPlayStore.ListDolphinLaunchCandidatesAsync(gameId, platform, ct));
|
||||
if (selected is null)
|
||||
return new(null, platform, "Exact Dolphin game is ambiguous",
|
||||
"Ludarium could not identify one unique read-only compatible image.", StatusCodes.Status409Conflict);
|
||||
return new(DolphinLaunchPolicy.ToRuntimePath(platform, selected.RelativePath, control.CatalogPrefix(platform)),
|
||||
platform);
|
||||
}
|
||||
|
||||
private async Task<string?> ResolvePlatformAsync(Guid gameId, CancellationToken ct)
|
||||
{
|
||||
var platforms = (await store.ListReleasesAsync(gameId, ct)).Select(release => release.Platform);
|
||||
var resolved = NativeRemotePlayerRegistry.ResolvePlatform(platforms);
|
||||
return NativeRemotePlayerRegistry.Dolphin.Owns(resolved) ? resolved : null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NativeLaunchResolvers(IEnumerable<INativeLaunchResolver> resolvers)
|
||||
{
|
||||
private readonly Dictionary<string, INativeLaunchResolver> byKey =
|
||||
resolvers.ToDictionary(resolver => resolver.Key, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public INativeLaunchResolver this[string key] => byKey.TryGetValue(key, out var resolver)
|
||||
? resolver
|
||||
: throw new KeyNotFoundException($"No native launch resolver is registered for '{key}'.");
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
using Ludarium.Application;
|
||||
using Ludarium.Domain;
|
||||
using Ludarium.Infrastructure;
|
||||
using Yarp.ReverseProxy.Forwarder;
|
||||
|
||||
namespace Ludarium.Api;
|
||||
|
||||
public sealed record NativeRemotePlayerAction(string? Action);
|
||||
|
||||
/// <summary>
|
||||
/// Maps the identical route surface every native remote player exposes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Routes, cookies and status codes are derived from <see cref="NativeRemotePlayer"/>, so a new
|
||||
/// player gets its capability, session, action, exit and proxy endpoints from its description alone.
|
||||
/// Only <see cref="INativeLaunchResolver"/> is written per player.
|
||||
/// </remarks>
|
||||
public static class NativeRemotePlayerEndpoints
|
||||
{
|
||||
private const int MaximumCapabilityBatch = 50;
|
||||
|
||||
/// <summary>How many restore points a player offers. The vault keeps the full history.</summary>
|
||||
private const int MaximumRestorePoints = 10;
|
||||
|
||||
public static void MapNativeRemotePlayers(this WebApplication app, RouteGroupBuilder api)
|
||||
{
|
||||
var runtimes = app.Services.GetRequiredService<NativeRemotePlayerRuntimes>();
|
||||
foreach (var runtime in runtimes.All)
|
||||
{
|
||||
MapCapabilities(api, runtime);
|
||||
MapSessions(app, api, runtime);
|
||||
MapSaveData(app, api, runtime);
|
||||
MapProxy(app, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Source type recorded on every vault revision captured from a native sidecar.</summary>
|
||||
public const string SaveDataSourceType = "NativeRemotePlayerV1";
|
||||
|
||||
/// <summary>
|
||||
/// Brings native remote players into the Game Data Vault.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Browser saves have always been versioned, SHA-256 evidenced revisions with a download history.
|
||||
/// Native players kept theirs inside the sidecar's own volume, where Ludarium could neither back
|
||||
/// them up nor restore them to a point. These two routes close that gap using the same vault.
|
||||
/// </remarks>
|
||||
private static void MapSaveData(WebApplication app, RouteGroupBuilder api, NativeRemotePlayerRuntime runtime)
|
||||
{
|
||||
var player = runtime.Player;
|
||||
var entryName = $"{player.Emulator} save data";
|
||||
|
||||
api.MapGet($"/games/{{id:guid}}/{player.Key}-save-data", async (Guid id,
|
||||
IGameDataVaultStore store, CancellationToken ct) =>
|
||||
{
|
||||
var entryId = BrowserGameDataPolicy.StableEntryId(id, player.Key, GameDataKind.Save);
|
||||
var entry = await store.GetGameDataAsync(entryId, ct);
|
||||
if (entry is null || entry.Entry.GameId != id)
|
||||
return Results.Ok(new { entryId, revisionCount = 0, revisions = Array.Empty<object>() });
|
||||
var revisions = await store.ListGameDataRevisionsAsync(entryId, 1, MaximumRestorePoints, ct);
|
||||
return Results.Ok(new
|
||||
{
|
||||
entryId,
|
||||
entry.Entry.RevisionCount,
|
||||
revisions = revisions.Items.Select(revision => new
|
||||
{
|
||||
revision.Id,
|
||||
revision.Sequence,
|
||||
revision.Length,
|
||||
revision.Sha256,
|
||||
revision.CreatedAt,
|
||||
current = revision.Id == entry.Entry.CurrentRevisionId
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
api.MapPost($"/games/{{id:guid}}/{player.Key}-save-data", async (Guid id, ILudariumStore catalog,
|
||||
IGameDataVaultStore store, GameDataVaultFileStore files, CancellationToken ct) =>
|
||||
{
|
||||
if (await catalog.GetGameAsync(id, ct) is null) return Results.NotFound();
|
||||
if (!runtime.Configured)
|
||||
return Results.Problem(title: $"Embedded {player.Emulator} player unavailable",
|
||||
detail: $"Enable the optional isolated {player.Emulator} deployment profile.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
// A capture taken while a title runs sees whatever the emulator has flushed. That is
|
||||
// usually the last in-game save, but it can be a partial write, so the revision says so
|
||||
// rather than presenting every capture as equally trustworthy.
|
||||
var running = (await runtime.Control.GetStatusAsync(ct)).Running;
|
||||
|
||||
await using var archive = await runtime.Control.DownloadSaveDataAsync(ct);
|
||||
if (archive is null)
|
||||
return Results.Problem(title: $"{player.Emulator} save data is unavailable",
|
||||
detail: $"The isolated {player.Emulator} runtime did not return its save directory, or its configured save directories are still empty.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
var entryId = BrowserGameDataPolicy.StableEntryId(id, player.Key, GameDataKind.Save);
|
||||
var existing = await store.GetGameDataAsync(entryId, ct);
|
||||
if (existing is not null && existing.Entry.GameId != id) return Results.Conflict();
|
||||
var uploaded = await files.SaveAsync(id, entryId, Guid.NewGuid(),
|
||||
$"{player.Key}-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.tar.gz", archive, null,
|
||||
$"{player.Key}-player", ct, SaveDataSourceType, player.Key);
|
||||
try
|
||||
{
|
||||
var saved = await store.SaveGameDataRevisionAsync(id, entryId,
|
||||
new(GameDataKind.Save, entryName, player.Emulator, "remote-player",
|
||||
running
|
||||
? $"Captured from the isolated {player.Emulator} runtime while a title was running."
|
||||
: $"Captured from the isolated {player.Emulator} runtime."),
|
||||
existing?.Entry.Version ?? 0, uploaded, $"{player.Key}-player", ct);
|
||||
if (saved.CurrentRevision.Id != uploaded.Id) files.Delete(uploaded);
|
||||
if (app.Logger.IsEnabled(LogLevel.Information))
|
||||
app.Logger.LogInformation("Captured {Emulator} save data revision {Sequence} for game {GameId}",
|
||||
player.Emulator, saved.CurrentRevision.Sequence, id);
|
||||
return Results.Ok(new
|
||||
{
|
||||
saved.Entry.Id,
|
||||
saved.Entry.Version,
|
||||
saved.Entry.RevisionCount,
|
||||
saved.CurrentRevision.Sequence,
|
||||
saved.CurrentRevision.Length,
|
||||
saved.CurrentRevision.Sha256,
|
||||
capturedWhileRunning = running
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
files.Delete(uploaded);
|
||||
throw;
|
||||
}
|
||||
});
|
||||
|
||||
api.MapPost($"/games/{{id:guid}}/{player.Key}-save-data/restore", async (Guid id, Guid? revisionId,
|
||||
IGameDataVaultStore store, GameDataVaultFileStore files, CancellationToken ct) =>
|
||||
{
|
||||
if (!runtime.Configured)
|
||||
return Results.Problem(title: $"Embedded {player.Emulator} player unavailable",
|
||||
detail: $"Enable the optional isolated {player.Emulator} deployment profile.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
var entryId = BrowserGameDataPolicy.StableEntryId(id, player.Key, GameDataKind.Save);
|
||||
var entry = await store.GetGameDataAsync(entryId, ct);
|
||||
if (entry is null || entry.Entry.GameId != id) return Results.NotFound();
|
||||
|
||||
var revision = revisionId is null ? entry.CurrentRevision
|
||||
: await store.GetGameDataRevisionAsync(revisionId.Value, ct);
|
||||
// A revision from another entry must never be pushed into this player's save directory.
|
||||
if (revision is null || revision.EntryId != entryId) return Results.NotFound();
|
||||
|
||||
var stored = await files.OpenAsync(revision, ct);
|
||||
if (stored is null)
|
||||
return Results.Problem(title: "Stored save data is missing",
|
||||
detail: "The vault no longer holds the payload for this revision.",
|
||||
statusCode: StatusCodes.Status410Gone);
|
||||
|
||||
await using var content = stored.Content;
|
||||
if (!await runtime.Control.UploadSaveDataAsync(content, stored.Length, ct))
|
||||
return Results.Problem(title: $"{player.Emulator} save data could not be restored",
|
||||
detail: $"The isolated {player.Emulator} runtime rejected the stored revision.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
if (app.Logger.IsEnabled(LogLevel.Information))
|
||||
app.Logger.LogInformation("Restored {Emulator} save data revision {Sequence} for game {GameId}",
|
||||
player.Emulator, revision.Sequence, id);
|
||||
return Results.Ok(new { revision.Id, revision.Sequence, revision.Sha256, revision.Length });
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A player is only usable when it is configured <em>and</em> its sidecar answers a live probe.
|
||||
/// Configuration alone proves intent, not that the container is up.
|
||||
/// </summary>
|
||||
private static async Task<(bool Ready, bool AutomaticRestore)> ProbeAsync(
|
||||
NativeRemotePlayerRuntime runtime, CancellationToken ct)
|
||||
{
|
||||
if (!runtime.Configured) return (false, false);
|
||||
var status = await runtime.Control.GetStatusAsync(ct);
|
||||
return (status.Reachable, status.Reachable && status.SaveDataSupported);
|
||||
}
|
||||
|
||||
private static void MapCapabilities(RouteGroupBuilder api, NativeRemotePlayerRuntime runtime)
|
||||
{
|
||||
var player = runtime.Player;
|
||||
|
||||
api.MapGet($"/games/{{id:guid}}/{player.Key}-play-capability",
|
||||
async (Guid id, NativeLaunchResolvers resolvers, CancellationToken ct) =>
|
||||
{
|
||||
var (ready, automaticRestore) = await ProbeAsync(runtime, ct);
|
||||
return Results.Ok(await resolvers[player.Key]
|
||||
.EvaluateAsync(id, ready, automaticRestore, DateTimeOffset.UtcNow, ct));
|
||||
});
|
||||
|
||||
api.MapGet($"/games/{player.Key}-play-capabilities",
|
||||
async (string? ids, NativeLaunchResolvers resolvers, CancellationToken ct) =>
|
||||
{
|
||||
if (!TryParseBatch(ids, out var gameIds))
|
||||
return Results.BadRequest(new { message = $"Supply between 1 and {MaximumCapabilityBatch} valid game ids." });
|
||||
var (ready, automaticRestore) = await ProbeAsync(runtime, ct);
|
||||
var checkedAt = DateTimeOffset.UtcNow;
|
||||
var resolver = resolvers[player.Key];
|
||||
var capabilities = await Task.WhenAll(gameIds.Select(gameId =>
|
||||
resolver.EvaluateAsync(gameId, ready, automaticRestore, checkedAt, ct)));
|
||||
return Results.Ok(capabilities);
|
||||
});
|
||||
}
|
||||
|
||||
private static void MapSessions(WebApplication app, RouteGroupBuilder api, NativeRemotePlayerRuntime runtime)
|
||||
{
|
||||
var player = runtime.Player;
|
||||
|
||||
api.MapPost($"/games/{{id:guid}}/{player.SessionRoute}", async (Guid id, HttpContext context,
|
||||
ILibraryExperienceStore experience, NativeLaunchResolvers resolvers, CancellationToken ct) =>
|
||||
{
|
||||
if (!runtime.Configured)
|
||||
return Results.Problem(title: $"Embedded {player.Emulator} player unavailable",
|
||||
detail: $"Enable the optional isolated {player.Emulator} deployment profile and complete its runtime gate.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
var resolution = await resolvers[player.Key].ResolveAsync(id, runtime.Control, ct);
|
||||
if (resolution.Rejected)
|
||||
return resolution.RejectStatus == StatusCodes.Status400BadRequest
|
||||
? Results.BadRequest(new { message = resolution.RejectDetail })
|
||||
: Results.Problem(title: resolution.RejectTitle, detail: resolution.RejectDetail,
|
||||
statusCode: resolution.RejectStatus);
|
||||
|
||||
try
|
||||
{
|
||||
var ticket = runtime.Sessions.Create(id, runtime.Proxy.BuildLaunchUrl(context.Request),
|
||||
runtime.Proxy.BuildExternalUrl(context.Request), resolution.Platform);
|
||||
try
|
||||
{
|
||||
await runtime.Control.LaunchAsync(resolution.RuntimePath!, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Sessions.End(ticket.Session.Id);
|
||||
throw;
|
||||
}
|
||||
context.Response.Cookies.Append(player.CookieName, ticket.Token,
|
||||
runtime.Sessions.Cookie(ticket.Session.ExpiresAt,
|
||||
runtime.Proxy.SecureCookies(context.Request),
|
||||
runtime.Proxy.CookieDomain(context.Request)));
|
||||
if (app.Logger.IsEnabled(LogLevel.Information))
|
||||
app.Logger.LogInformation("Embedded {Emulator} player session {SessionId} started for game {GameId}",
|
||||
player.Emulator, ticket.Session.Id, id);
|
||||
try { await experience.RecordGamePlayedAsync(id, "local-admin", ct); }
|
||||
catch (Exception exception)
|
||||
{
|
||||
app.Logger.LogWarning(exception, "Could not record {Emulator} play history for game {GameId}",
|
||||
player.Emulator, id);
|
||||
}
|
||||
return Results.Created($"/api/v1/{player.SessionRoute}/{ticket.Session.Id}", ticket.Session);
|
||||
}
|
||||
catch (NativeRemotePlayerCapacityException exception)
|
||||
{
|
||||
return Results.Problem(title: $"{player.Emulator} player capacity reached",
|
||||
detail: exception.Message, statusCode: StatusCodes.Status429TooManyRequests);
|
||||
}
|
||||
catch (NativeRemotePlayerControlException exception)
|
||||
{
|
||||
return Results.Problem(title: $"{player.Emulator} title could not start",
|
||||
detail: exception.Message, statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
});
|
||||
|
||||
api.MapPost($"/{player.SessionRoute}/{{id:guid}}/actions",
|
||||
async (Guid id, NativeRemotePlayerAction input, CancellationToken ct) =>
|
||||
{
|
||||
if (runtime.Sessions.Get(id) is null) return Results.NotFound();
|
||||
var action = input.Action?.Trim().ToLowerInvariant();
|
||||
if (action is null || !player.Actions.Contains(action))
|
||||
return Results.BadRequest(new { message = $"Unsupported {player.Emulator} player action." });
|
||||
try
|
||||
{
|
||||
await runtime.Control.ActionAsync(action, ct);
|
||||
return Results.Accepted();
|
||||
}
|
||||
catch (NativeRemotePlayerControlException exception)
|
||||
{
|
||||
return Results.Problem(title: $"{player.Emulator} player control failed",
|
||||
detail: exception.Message, statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
});
|
||||
|
||||
// This route is protected by the administrator bearer token. The player capability cookie is
|
||||
// intentionally scoped to the proxy path and therefore cannot authorize an API request.
|
||||
api.MapDelete($"/{player.SessionRoute}/{{id:guid}}", async (Guid id, HttpContext context,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var ended = runtime.Sessions.End(id);
|
||||
context.Response.Cookies.Delete(player.CookieName,
|
||||
runtime.Sessions.ExpiredCookie(runtime.Proxy.SecureCookies(context.Request),
|
||||
runtime.Proxy.CookieDomain(context.Request)));
|
||||
// Revoking the capability must also end the title. A running emulator that outlives its
|
||||
// session keeps a render device and the disc image busy with no way left to reach it.
|
||||
if (ended && !runtime.Sessions.HasActiveSession)
|
||||
{
|
||||
var stopped = await runtime.Control.TryStopAsync(ct);
|
||||
if (!stopped && app.Logger.IsEnabled(LogLevel.Warning))
|
||||
app.Logger.LogWarning("Could not stop the {Emulator} title after session {SessionId} ended",
|
||||
player.Emulator, id);
|
||||
}
|
||||
if (ended && app.Logger.IsEnabled(LogLevel.Information))
|
||||
app.Logger.LogInformation("Embedded {Emulator} player session {SessionId} ended",
|
||||
player.Emulator, id);
|
||||
return ended ? Results.NoContent() : Results.NotFound();
|
||||
});
|
||||
}
|
||||
|
||||
private static void MapProxy(WebApplication app, NativeRemotePlayerRuntime runtime)
|
||||
{
|
||||
var player = runtime.Player;
|
||||
app.Map($"{player.ProxyPath}/{{**catchAll}}", async (HttpContext context) =>
|
||||
{
|
||||
if (!runtime.Proxy.Configured)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
await context.Response.WriteAsync($"The embedded {player.Emulator} player is not configured.",
|
||||
context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
if (!runtime.Proxy.AcceptsProxyRequest(context.Request))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
return;
|
||||
}
|
||||
if (!runtime.Sessions.Authorizes(context.Request.Cookies[player.CookieName]))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
context.Response.ContentType = "text/plain; charset=utf-8";
|
||||
await context.Response.WriteAsync(
|
||||
$"This {player.Emulator} player session is missing, expired or closed.", context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
var error = await runtime.Forwarder.SendAsync(context, runtime.Proxy);
|
||||
if (error != ForwarderError.None && !context.Response.HasStarted)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
await context.Response.WriteAsync($"The isolated {player.Emulator} runtime could not be reached.",
|
||||
context.RequestAborted);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryParseBatch(string? ids, out Guid[] gameIds)
|
||||
{
|
||||
var requested = (ids ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (requested.Length is 0 or > MaximumCapabilityBatch || requested.Any(value => !Guid.TryParse(value, out _)))
|
||||
{
|
||||
gameIds = [];
|
||||
return false;
|
||||
}
|
||||
gameIds = requested.Select(Guid.Parse).Distinct().ToArray();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Ludarium.Application;
|
||||
using Yarp.ReverseProxy.Forwarder;
|
||||
|
||||
namespace Ludarium.Api;
|
||||
|
||||
/// <summary>
|
||||
/// The transport every isolated native remote player shares: configuration, capability-scoped
|
||||
/// sessions, the authenticated exact-title control client and the certificate-pinned proxy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This used to exist once per emulator. That is how the live Eden display fix never reached
|
||||
/// Dolphin, and it is why adding 3DS, Xbox, Wii U or Vita meant copying a vertical slice. A player
|
||||
/// is now described by <see cref="NativeRemotePlayer"/> and everything below is shared.
|
||||
/// </remarks>
|
||||
public sealed record NativeRemotePlayerSessionDescriptor(Guid Id, Guid GameId, string Player, string Emulator,
|
||||
string? Platform, string LaunchUrl, string? ExternalUrl, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt,
|
||||
bool ExactGameLaunch, string SaveMode);
|
||||
|
||||
public sealed record NativeRemotePlayerSessionTicket(NativeRemotePlayerSessionDescriptor Session, string Token);
|
||||
|
||||
public sealed class NativeRemotePlayerCapacityException(NativeRemotePlayer player)
|
||||
: Exception($"The isolated {player.Emulator} player is already in use.");
|
||||
|
||||
public sealed class NativeRemotePlayerControlException(NativeRemotePlayer player)
|
||||
: Exception($"The isolated {player.Emulator} controller rejected the requested operation.");
|
||||
|
||||
public sealed class NativeRemotePlayerProxyOptions
|
||||
{
|
||||
public NativeRemotePlayer Player { get; }
|
||||
public Uri? Destination { get; }
|
||||
public Uri? ExternalPlayer { get; }
|
||||
public Uri? PublicOrigin { get; }
|
||||
public string? Username { get; }
|
||||
public string? Password { get; }
|
||||
public byte[]? CertificateSha256 { get; }
|
||||
public int PublicPort { get; }
|
||||
|
||||
public bool Configured => Destination is not null && !string.IsNullOrWhiteSpace(Username) &&
|
||||
!string.IsNullOrWhiteSpace(Password) && CertificateSha256 is { Length: 32 } &&
|
||||
PublicPort is > 0 and <= 65535;
|
||||
|
||||
public NativeRemotePlayerProxyOptions(NativeRemotePlayer player, Uri? destination, Uri? externalPlayer,
|
||||
string? username, string? password, int? publicPort = null, string? certificateSha256 = null,
|
||||
Uri? publicOrigin = null)
|
||||
{
|
||||
Player = player;
|
||||
// Only the sidecar's own internal name is ever a valid destination: the proxy must never be
|
||||
// pointed at an arbitrary host by configuration.
|
||||
Destination = destination is { Scheme: "https", UserInfo.Length: 0, Query.Length: 0, Fragment.Length: 0, AbsolutePath: "/" } &&
|
||||
string.Equals(destination.Host, player.ProxyHost, StringComparison.OrdinalIgnoreCase) ? destination : null;
|
||||
ExternalPlayer = externalPlayer is { UserInfo.Length: 0 } && externalPlayer.Scheme is "http" or "https"
|
||||
? externalPlayer : null;
|
||||
PublicOrigin = publicOrigin is { Scheme: "https", UserInfo.Length: 0, Query.Length: 0, Fragment.Length: 0, AbsolutePath: "/" }
|
||||
? publicOrigin : null;
|
||||
Username = string.IsNullOrWhiteSpace(username) ? null : username;
|
||||
Password = string.IsNullOrWhiteSpace(password) ? null : password;
|
||||
PublicPort = publicPort ?? player.DefaultPublicPort;
|
||||
CertificateSha256 = ParseSha256(certificateSha256);
|
||||
}
|
||||
|
||||
public static NativeRemotePlayerProxyOptions FromEnvironment(NativeRemotePlayer player)
|
||||
{
|
||||
string? Value(string name) => Environment.GetEnvironmentVariable($"{player.EnvironmentPrefix}_{name}");
|
||||
_ = Uri.TryCreate(Value("PROXY_URL"), UriKind.Absolute, out var destination);
|
||||
_ = Uri.TryCreate(Value("PLAYER_URL"), UriKind.Absolute, out var external);
|
||||
_ = Uri.TryCreate(Value("EMBED_ORIGIN"), UriKind.Absolute, out var origin);
|
||||
var port = int.TryParse(Value("EMBED_PORT"), out var parsed) ? parsed : player.DefaultPublicPort;
|
||||
return new(player, destination, external, Value("PROXY_USERNAME"), Value("PROXY_PASSWORD"), port,
|
||||
Value("PROXY_CERT_SHA256"), origin);
|
||||
}
|
||||
|
||||
public string BuildLaunchUrl(HttpRequest request)
|
||||
{
|
||||
if (!Configured) throw new InvalidOperationException($"The embedded {Player.Emulator} player is not configured.");
|
||||
if (PublicOrigin is not null)
|
||||
{
|
||||
if (!IsChildHost(PublicOrigin.Host, request.Host.Host))
|
||||
throw new InvalidOperationException($"The {Player.Emulator} player origin must be a subdomain of the Ludarium request host.");
|
||||
return new Uri(PublicOrigin, Player.ProxyPath + "/").ToString();
|
||||
}
|
||||
return new UriBuilder(request.Scheme, request.Host.Host, PublicPort, Player.ProxyPath + "/").Uri.ToString();
|
||||
}
|
||||
|
||||
public string? BuildExternalUrl(HttpRequest request) =>
|
||||
PublicOrigin is null ? ExternalPlayer?.ToString() : BuildLaunchUrl(request);
|
||||
|
||||
public string? CookieDomain(HttpRequest request) => PublicOrigin is null ? null :
|
||||
IsChildHost(PublicOrigin.Host, request.Host.Host) ? request.Host.Host :
|
||||
throw new InvalidOperationException($"The {Player.Emulator} player origin must be a subdomain of the Ludarium request host.");
|
||||
|
||||
public bool AcceptsProxyRequest(HttpRequest request) => PublicOrigin is null ||
|
||||
string.Equals(request.Host.Host, PublicOrigin.Host, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool SecureCookies(HttpRequest request) =>
|
||||
request.IsHttps || PublicOrigin?.Scheme == Uri.UriSchemeHttps;
|
||||
|
||||
private static bool IsChildHost(string child, string parent) =>
|
||||
child.EndsWith('.' + parent, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static byte[]? ParseSha256(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value.Length != 64) return null;
|
||||
try { return Convert.FromHexString(value); }
|
||||
catch (FormatException) { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NativeRemotePlayerSessionStore(NativeRemotePlayer player, TimeProvider timeProvider)
|
||||
{
|
||||
public static readonly TimeSpan SessionLifetime = TimeSpan.FromHours(8);
|
||||
private readonly ConcurrentDictionary<Guid, Entry> sessions = new();
|
||||
private readonly object gate = new();
|
||||
|
||||
public NativeRemotePlayer Player { get; } = player;
|
||||
public string CookieName => Player.CookieName;
|
||||
public string ProxyPath => Player.ProxyPath;
|
||||
|
||||
public NativeRemotePlayerSessionTicket Create(Guid gameId, string launchUrl, string? externalUrl,
|
||||
string? platform = null, bool exactGameLaunch = true)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
RemoveExpired();
|
||||
if (sessions.Count >= Player.MaximumSessions) throw new NativeRemotePlayerCapacityException(Player);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var token = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
var descriptor = new NativeRemotePlayerSessionDescriptor(Guid.NewGuid(), gameId, Player.Key,
|
||||
Player.Emulator, platform, launchUrl, externalUrl, now, now.Add(SessionLifetime),
|
||||
exactGameLaunch, Player.SaveMode);
|
||||
sessions[descriptor.Id] = new Entry(descriptor, SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
return new(descriptor, token);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Authorizes(string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)) return false;
|
||||
var supplied = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
var now = timeProvider.GetUtcNow();
|
||||
foreach (var entry in sessions.Values)
|
||||
if (entry.Descriptor.ExpiresAt > now &&
|
||||
CryptographicOperations.FixedTimeEquals(supplied, entry.TokenHash))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public NativeRemotePlayerSessionDescriptor? Get(Guid id) => sessions.TryGetValue(id, out var entry) &&
|
||||
entry.Descriptor.ExpiresAt > timeProvider.GetUtcNow() ? entry.Descriptor : null;
|
||||
|
||||
public bool End(Guid id) => sessions.TryRemove(id, out _);
|
||||
|
||||
/// <summary>True while any capability this store issued is still live.</summary>
|
||||
public bool HasActiveSession
|
||||
{
|
||||
get
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return sessions.Values.Any(entry => entry.Descriptor.ExpiresAt > now);
|
||||
}
|
||||
}
|
||||
|
||||
public CookieOptions Cookie(DateTimeOffset expires, bool secure, string? domain = null) => new()
|
||||
{
|
||||
HttpOnly = true,
|
||||
IsEssential = true,
|
||||
Path = ProxyPath,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Secure = secure,
|
||||
Domain = domain,
|
||||
Expires = expires
|
||||
};
|
||||
|
||||
public CookieOptions ExpiredCookie(bool secure, string? domain = null) =>
|
||||
Cookie(DateTimeOffset.UnixEpoch, secure, domain);
|
||||
|
||||
private void RemoveExpired()
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
foreach (var entry in sessions)
|
||||
if (entry.Value.Descriptor.ExpiresAt <= now) sessions.TryRemove(entry.Key, out _);
|
||||
}
|
||||
|
||||
private static string Base64Url(byte[] value) =>
|
||||
Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
private sealed record Entry(NativeRemotePlayerSessionDescriptor Descriptor, byte[] TokenHash);
|
||||
}
|
||||
|
||||
/// <summary>Live state reported by a sidecar's own control endpoint.</summary>
|
||||
public sealed record NativeRemotePlayerRuntimeStatus(bool Reachable, bool Running, string? SaveMode,
|
||||
bool SaveDataSupported = false);
|
||||
|
||||
public sealed class NativeRemotePlayerControlClient
|
||||
{
|
||||
private readonly HttpClient client;
|
||||
private readonly string? token;
|
||||
private readonly TimeProvider time;
|
||||
private readonly Lock probeGate = new();
|
||||
private NativeRemotePlayerRuntimeStatus? cachedStatus;
|
||||
private DateTimeOffset cachedAt = DateTimeOffset.MinValue;
|
||||
|
||||
/// <summary>How long a live probe result is reused, so capability reads stay cheap.</summary>
|
||||
public static readonly TimeSpan HealthCacheLifetime = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// A readiness probe must never hold up a catalog page. A sidecar that accepts the connection but
|
||||
/// does not answer would otherwise stall every capability read for the full control timeout.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan HealthProbeTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
public NativeRemotePlayer Player { get; }
|
||||
public IReadOnlyDictionary<string, string> CatalogPrefixes { get; }
|
||||
public string? QualifiedFixtureSha256 { get; }
|
||||
|
||||
public bool Configured =>
|
||||
client.BaseAddress is { Scheme: "http", AbsolutePath: "/" } address &&
|
||||
string.Equals(address.Host, Player.ControlHost, StringComparison.OrdinalIgnoreCase) &&
|
||||
token is not null && (!Player.RequiresFixtureDigest || QualifiedFixtureSha256 is not null);
|
||||
|
||||
public NativeRemotePlayerControlClient(NativeRemotePlayer player, HttpClient client, string? token,
|
||||
IReadOnlyDictionary<string, string> catalogPrefixes, string? fixtureSha256 = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
Player = player;
|
||||
this.client = client;
|
||||
this.token = string.IsNullOrWhiteSpace(token) ? null : token;
|
||||
this.time = timeProvider ?? TimeProvider.System;
|
||||
CatalogPrefixes = catalogPrefixes;
|
||||
QualifiedFixtureSha256 = IsValidSha256(fixtureSha256) ? fixtureSha256!.ToLowerInvariant() : null;
|
||||
}
|
||||
|
||||
public static NativeRemotePlayerControlClient FromEnvironment(NativeRemotePlayer player)
|
||||
{
|
||||
string? Value(string name) => Environment.GetEnvironmentVariable($"{player.EnvironmentPrefix}_{name}");
|
||||
_ = Uri.TryCreate(Value("CONTROL_URL"), UriKind.Absolute, out var address);
|
||||
var prefixes = player.Platforms.ToDictionary(
|
||||
platform => platform,
|
||||
platform => Value(CatalogPrefixVariable(player, platform)) ?? $"roms/{platform}",
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
return new(player, new HttpClient { BaseAddress = address, Timeout = TimeSpan.FromSeconds(15) },
|
||||
Value("CONTROL_TOKEN"), prefixes, Value("FIXTURE_SHA256"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single-platform player keeps the flat variable it already ships with; a multi-platform
|
||||
/// player names its platform, matching the deployed Dolphin configuration.
|
||||
/// </summary>
|
||||
public static string CatalogPrefixVariable(NativeRemotePlayer player, string platform) =>
|
||||
player.Platforms.Count == 1 ? "CATALOG_PREFIX" : $"{platform.ToUpperInvariant()}_CATALOG_PREFIX";
|
||||
|
||||
public static bool IsValidSha256(string? value) => value is { Length: 64 } && value.All(Uri.IsHexDigit);
|
||||
|
||||
public string CatalogPrefix(string platform) => CatalogPrefixes.TryGetValue(platform, out var prefix)
|
||||
? prefix
|
||||
: throw new InvalidOperationException($"No catalog prefix is configured for {platform}.");
|
||||
|
||||
public Task LaunchAsync(string path, CancellationToken ct) => SendAsync("v1/launch", new { path }, ct);
|
||||
|
||||
public Task ActionAsync(string action, CancellationToken ct) => SendAsync("v1/action", new { action }, ct);
|
||||
|
||||
/// <summary>Best-effort stop used when a player session ends, so no title outlives its capability.</summary>
|
||||
public async Task<bool> TryStopAsync(CancellationToken ct)
|
||||
{
|
||||
if (!Configured) return false;
|
||||
try { await ActionAsync("stop", ct); return true; }
|
||||
catch (NativeRemotePlayerControlException) { return false; }
|
||||
catch (HttpRequestException) { return false; }
|
||||
catch (TaskCanceledException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ask the sidecar whether it is actually alive. Configuration alone only proves an operator
|
||||
/// intended a runtime; it does not prove the container is up, so a capability that claims
|
||||
/// availability must be backed by a recent live answer.
|
||||
/// </summary>
|
||||
public async Task<NativeRemotePlayerRuntimeStatus> GetStatusAsync(CancellationToken ct)
|
||||
{
|
||||
if (!Configured) return new(false, false, null);
|
||||
var now = time.GetUtcNow();
|
||||
lock (probeGate)
|
||||
if (cachedStatus is not null && now - cachedAt < HealthCacheLifetime) return cachedStatus;
|
||||
|
||||
NativeRemotePlayerRuntimeStatus status;
|
||||
using var probe = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
probe.CancelAfter(HealthProbeTimeout);
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "v1/status");
|
||||
request.Headers.Add("X-Ludarium-Control-Token", token);
|
||||
using var response = await client.SendAsync(request, probe.Token);
|
||||
status = response.IsSuccessStatusCode
|
||||
? Parse(await response.Content.ReadAsStringAsync(probe.Token))
|
||||
: new(false, false, null);
|
||||
}
|
||||
catch (Exception exception) when (exception is HttpRequestException or OperationCanceledException or JsonException)
|
||||
{
|
||||
// A caller-cancelled request must not be cached as an unreachable runtime.
|
||||
if (ct.IsCancellationRequested) throw;
|
||||
status = new(false, false, null);
|
||||
}
|
||||
|
||||
lock (probeGate)
|
||||
{
|
||||
cachedStatus = status;
|
||||
cachedAt = now;
|
||||
}
|
||||
return status;
|
||||
|
||||
static NativeRemotePlayerRuntimeStatus Parse(string body)
|
||||
{
|
||||
using var document = JsonDocument.Parse(body);
|
||||
var root = document.RootElement;
|
||||
var running = root.TryGetProperty("running", out var value) && value.ValueKind == JsonValueKind.True;
|
||||
var saveMode = root.TryGetProperty("saveMode", out var mode) ? mode.GetString() : null;
|
||||
var saveData = root.TryGetProperty("saveData", out var data) && data.ValueKind == JsonValueKind.True;
|
||||
return new(true, running, saveMode, saveData);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the sidecar's current app-owned save directory as one bounded archive.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Native player saves live inside the sidecar's own volume, which the Ludarium container cannot
|
||||
/// read. Moving them over the existing authenticated control channel is what lets them become
|
||||
/// versioned, SHA-256 evidenced vault revisions like browser saves, instead of an opaque blob
|
||||
/// with no export and no restore point.
|
||||
/// </remarks>
|
||||
public async Task<Stream?> DownloadSaveDataAsync(CancellationToken ct)
|
||||
{
|
||||
if (!Configured) return null;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "v1/save-data");
|
||||
request.Headers.Add("X-Ludarium-Control-Token", token);
|
||||
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
response.Dispose();
|
||||
return null;
|
||||
}
|
||||
return await response.Content.ReadAsStreamAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Push a stored revision back into the sidecar's save directory.</summary>
|
||||
public async Task<bool> UploadSaveDataAsync(Stream content, long length, CancellationToken ct)
|
||||
{
|
||||
if (!Configured) return false;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/save-data")
|
||||
{ Content = new StreamContent(content) };
|
||||
request.Content.Headers.ContentLength = length;
|
||||
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/gzip");
|
||||
request.Headers.Add("X-Ludarium-Control-Token", token);
|
||||
using var response = await client.SendAsync(request, ct);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
private async Task SendAsync(string path, object body, CancellationToken ct)
|
||||
{
|
||||
if (!Configured) throw new InvalidOperationException($"The isolated {Player.Emulator} controller is not configured.");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, path)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json")
|
||||
};
|
||||
request.Headers.Add("X-Ludarium-Control-Token", token);
|
||||
using var response = await client.SendAsync(request, ct);
|
||||
if (!response.IsSuccessStatusCode) throw new NativeRemotePlayerControlException(Player);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NativeRemotePlayerForwarder : IDisposable
|
||||
{
|
||||
private static readonly ForwarderRequestConfig RequestConfig = new() { ActivityTimeout = TimeSpan.FromHours(12) };
|
||||
private readonly IHttpForwarder forwarder;
|
||||
private readonly HttpMessageInvoker client;
|
||||
private readonly Transformer transformer;
|
||||
|
||||
public NativeRemotePlayerForwarder(IHttpForwarder forwarder, NativeRemotePlayerProxyOptions options)
|
||||
{
|
||||
this.forwarder = forwarder;
|
||||
client = new HttpMessageInvoker(new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
AutomaticDecompression = DecompressionMethods.None,
|
||||
EnableMultipleHttp2Connections = true,
|
||||
UseCookies = false,
|
||||
UseProxy = false,
|
||||
SslOptions = new System.Net.Security.SslClientAuthenticationOptions
|
||||
{
|
||||
RemoteCertificateValidationCallback = (_, certificate, _, _) => certificate is not null &&
|
||||
options.CertificateSha256 is not null &&
|
||||
CryptographicOperations.FixedTimeEquals(
|
||||
certificate.GetCertHash(HashAlgorithmName.SHA256), options.CertificateSha256)
|
||||
}
|
||||
});
|
||||
transformer = new(options.Username, options.Password);
|
||||
}
|
||||
|
||||
public ValueTask<ForwarderError> SendAsync(HttpContext context, NativeRemotePlayerProxyOptions options) =>
|
||||
forwarder.SendAsync(context, options.Destination!.ToString(), client, RequestConfig, transformer);
|
||||
|
||||
public void Dispose() => client.Dispose();
|
||||
|
||||
private sealed class Transformer : HttpTransformer
|
||||
{
|
||||
private readonly AuthenticationHeaderValue authorization;
|
||||
|
||||
public Transformer(string? username, string? password) => authorization = new("Basic",
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")));
|
||||
|
||||
public override async ValueTask TransformRequestAsync(HttpContext context, HttpRequestMessage request,
|
||||
string destinationPrefix, CancellationToken ct)
|
||||
{
|
||||
await base.TransformRequestAsync(context, request, destinationPrefix, ct);
|
||||
request.Headers.Host = null;
|
||||
request.Headers.Remove("Cookie");
|
||||
request.Headers.Authorization = authorization;
|
||||
}
|
||||
|
||||
public override async ValueTask<bool> TransformResponseAsync(HttpContext context,
|
||||
HttpResponseMessage? response, CancellationToken ct)
|
||||
{
|
||||
var body = await base.TransformResponseAsync(context, response, ct);
|
||||
context.Response.Headers.Remove("WWW-Authenticate");
|
||||
context.Response.Headers.Remove("X-Frame-Options");
|
||||
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
return body;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Everything one configured native remote player needs at request time.</summary>
|
||||
public sealed class NativeRemotePlayerRuntime(
|
||||
NativeRemotePlayer player,
|
||||
NativeRemotePlayerProxyOptions proxy,
|
||||
NativeRemotePlayerSessionStore sessions,
|
||||
NativeRemotePlayerControlClient control,
|
||||
Func<NativeRemotePlayerProxyOptions, NativeRemotePlayerForwarder> forwarderFactory) : IDisposable
|
||||
{
|
||||
private readonly Lock gate = new();
|
||||
private NativeRemotePlayerForwarder? forwarder;
|
||||
|
||||
public NativeRemotePlayer Player { get; } = player;
|
||||
public NativeRemotePlayerProxyOptions Proxy { get; } = proxy;
|
||||
public NativeRemotePlayerSessionStore Sessions { get; } = sessions;
|
||||
public NativeRemotePlayerControlClient Control { get; } = control;
|
||||
|
||||
/// <summary>Configuration is complete for both the embedded proxy and exact-title control.</summary>
|
||||
public bool Configured => Proxy.Configured && Control.Configured;
|
||||
|
||||
public NativeRemotePlayerForwarder Forwarder
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate) return forwarder ??= forwarderFactory(Proxy);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => forwarder?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>All native remote players this deployment knows about, configured or not.</summary>
|
||||
public sealed class NativeRemotePlayerRuntimes(IReadOnlyList<NativeRemotePlayerRuntime> runtimes) : IDisposable
|
||||
{
|
||||
public IReadOnlyList<NativeRemotePlayerRuntime> All { get; } = runtimes;
|
||||
|
||||
public NativeRemotePlayerRuntime this[string key] =>
|
||||
All.FirstOrDefault(runtime => runtime.Player.Key.Equals(key, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new KeyNotFoundException($"No native remote player is registered for '{key}'.");
|
||||
|
||||
public NativeRemotePlayerRuntime? ForPlatform(string? platform) =>
|
||||
platform is null ? null : All.FirstOrDefault(runtime => runtime.Player.Owns(platform));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var runtime in All) runtime.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5135",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.AspNetCore.OpenApi": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.11, )",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "R/1EATnPLU+gRfB6lwVkMcymmyAY5ppBBdRN/5lhNEiT3xP1sWccuSFkU/f1lQvN/WgRq5Vn8AhCdE3fqgsL/w==",
|
||||
"dependencies": {
|
||||
"Microsoft.OpenApi": "[2.7.5, 3.0.0)"
|
||||
}
|
||||
},
|
||||
"Microsoft.OpenApi": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.11.0, )",
|
||||
"resolved": "2.11.0",
|
||||
"contentHash": "/ignjfdeKT2SGLIR7QEv19KnI0rvoxRG/TYDOZdK9EsWLjKK9IK8i1Mo5NRm9PRV3i64DzlTqnIflWvoyfljLg=="
|
||||
},
|
||||
"Yarp.ReverseProxy": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.3.0, )",
|
||||
"resolved": "2.3.0",
|
||||
"contentHash": "gxtkN3a+9biu9V9Zd5NaTO6VZWXAnS2mhQ0R/VXmSPoTuiQNZsakKikrKpDtKxrL5nUYzbRsHtl40WNq+ZBKKg==",
|
||||
"dependencies": {
|
||||
"System.IO.Hashing": "8.0.0"
|
||||
}
|
||||
},
|
||||
"Npgsql": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.3",
|
||||
"contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w=="
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"ludarium.infrastructure": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Ludarium.Application": "[1.0.0, )",
|
||||
"Ludarium.Domain": "[1.0.0, )",
|
||||
"Npgsql": "[10.0.3, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.IO.Hashing;
|
||||
using System.Reflection.Metadata;
|
||||
using System.Reflection.PortableExecutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Ludarium.Domain;
|
||||
using OpenMcdf;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public static partial class ArtifactAnalysis
|
||||
{
|
||||
private static readonly Dictionary<string, string> PlatformDirectories = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["pc"] = "windows",
|
||||
["windows"] = "windows",
|
||||
["nes"] = "nes",
|
||||
["snes"] = "snes",
|
||||
["n64"] = "n64",
|
||||
["gb"] = "gb",
|
||||
["gbc"] = "gbc",
|
||||
["gba"] = "gba",
|
||||
["nds"] = "nds",
|
||||
["3ds"] = "3ds",
|
||||
["ngc"] = "gamecube",
|
||||
["gamecube"] = "gamecube",
|
||||
["wii"] = "wii",
|
||||
["switch"] = "switch",
|
||||
["ps1"] = "psx",
|
||||
["psx"] = "psx",
|
||||
["ps2"] = "ps2",
|
||||
["ps3"] = "ps3",
|
||||
["ps4"] = "ps4",
|
||||
["ps5"] = "ps5",
|
||||
["psp"] = "psp",
|
||||
["psv"] = "psvita",
|
||||
["psvita"] = "psvita",
|
||||
["wiiu"] = "wiiu",
|
||||
["xbox"] = "xbox",
|
||||
["xbox360"] = "xbox360",
|
||||
// Sega directories vary widely between collections; every common spelling resolves to the
|
||||
// canonical platform so a hardware-named folder is never silently ignored.
|
||||
["genesis"] = "genesis",
|
||||
["megadrive"] = "genesis",
|
||||
["mega-drive"] = "genesis",
|
||||
["sega-genesis"] = "genesis",
|
||||
["segagenesis"] = "genesis",
|
||||
["md"] = "genesis",
|
||||
["sms"] = "mastersystem",
|
||||
["mastersystem"] = "mastersystem",
|
||||
["master-system"] = "mastersystem",
|
||||
["gg"] = "gamegear",
|
||||
["gamegear"] = "gamegear",
|
||||
["game-gear"] = "gamegear",
|
||||
["32x"] = "sega32x",
|
||||
["sega32x"] = "sega32x",
|
||||
["segacd"] = "segacd",
|
||||
["saturn"] = "saturn",
|
||||
["dreamcast"] = "dreamcast"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Platform values written by earlier Ludarium versions, mapped to their canonical name so a
|
||||
/// catalog scanned before the Sega platforms existed keeps resolving after an upgrade.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> PlatformAliases = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sega-genesis"] = "genesis",
|
||||
["megadrive"] = "genesis",
|
||||
["sms"] = "mastersystem",
|
||||
["gg"] = "gamegear"
|
||||
};
|
||||
|
||||
/// <summary>Resolve any historical or shorthand platform value to its canonical name.</summary>
|
||||
public static string? CanonicalPlatform(string? platform) => platform is null ? null
|
||||
: PlatformAliases.TryGetValue(platform, out var canonical) ? canonical : platform;
|
||||
|
||||
private static readonly Dictionary<string, (MediaType Type, string? Platform)> Extensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[".nes"] = (MediaType.Rom, "nes"),
|
||||
[".sfc"] = (MediaType.Rom, "snes"),
|
||||
[".smc"] = (MediaType.Rom, "snes"),
|
||||
[".z64"] = (MediaType.Rom, "n64"),
|
||||
[".n64"] = (MediaType.Rom, "n64"),
|
||||
[".v64"] = (MediaType.Rom, "n64"),
|
||||
[".gb"] = (MediaType.Rom, "gb"),
|
||||
[".gbc"] = (MediaType.Rom, "gbc"),
|
||||
[".gba"] = (MediaType.Rom, "gba"),
|
||||
[".nds"] = (MediaType.Rom, "nds"),
|
||||
[".cue"] = (MediaType.DiscDescriptor, null),
|
||||
[".m3u"] = (MediaType.DiscDescriptor, null),
|
||||
[".iso"] = (MediaType.DiscImage, null),
|
||||
[".chd"] = (MediaType.DiscImage, null),
|
||||
[".cso"] = (MediaType.DiscImage, null),
|
||||
[".isz"] = (MediaType.DiscImage, null),
|
||||
[".rvz"] = (MediaType.DiscImage, null),
|
||||
[".wbfs"] = (MediaType.DiscImage, null),
|
||||
// GameCube and Wii container formats the Dolphin player accepts. Without these a linked
|
||||
// .gcm/.gcz/.wia disc classified as Unknown and could never become a launch candidate.
|
||||
[".gcm"] = (MediaType.DiscImage, null),
|
||||
[".gcz"] = (MediaType.DiscImage, null),
|
||||
[".wia"] = (MediaType.DiscImage, null),
|
||||
[".wud"] = (MediaType.DiscImage, "wiiu"),
|
||||
[".wux"] = (MediaType.DiscImage, "wiiu"),
|
||||
[".gdi"] = (MediaType.DiscDescriptor, null),
|
||||
[".cdi"] = (MediaType.DiscImage, "dreamcast"),
|
||||
[".pbp"] = (MediaType.DiscImage, null),
|
||||
[".3ds"] = (MediaType.Rom, "3ds"),
|
||||
[".cia"] = (MediaType.Rom, "3ds"),
|
||||
[".vpk"] = (MediaType.Archive, "psvita"),
|
||||
[".md"] = (MediaType.Rom, "genesis"),
|
||||
[".gen"] = (MediaType.Rom, "genesis"),
|
||||
[".smd"] = (MediaType.Rom, "genesis"),
|
||||
[".32x"] = (MediaType.Rom, "sega32x"),
|
||||
[".sms"] = (MediaType.Rom, "mastersystem"),
|
||||
[".gg"] = (MediaType.Rom, "gamegear"),
|
||||
[".sg"] = (MediaType.Rom, "sg1000"),
|
||||
[".xci"] = (MediaType.DiscImage, "switch"),
|
||||
[".nsp"] = (MediaType.DiscImage, "switch"),
|
||||
[".zip"] = (MediaType.Archive, null),
|
||||
[".7z"] = (MediaType.Archive, null),
|
||||
[".exe"] = (MediaType.WindowsPackage, "windows"),
|
||||
[".msi"] = (MediaType.WindowsPackage, "windows"),
|
||||
[".msix"] = (MediaType.WindowsPackage, "windows"),
|
||||
[".appx"] = (MediaType.WindowsPackage, "windows"),
|
||||
[".pdf"] = (MediaType.Document, null),
|
||||
[".txt"] = (MediaType.Document, null)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Platforms whose dumps are raw disc tracks. A bare BIN or IMG is only a disc track inside one
|
||||
/// of these libraries; elsewhere it is far more likely to be an installer payload, so it stays
|
||||
/// unclassified rather than being promoted to a disc image on a guess.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> DiscTrackPlatforms = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"psx", "ps2", "segacd", "saturn", "dreamcast", "3do", "pcecd"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Every extension the identifier recognises, without its leading dot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A derived title has to strip the same set, and that list used to be maintained separately in
|
||||
/// SQL. It drifted: a Mega Drive ROM became "Sonic md" because the query had never heard of the
|
||||
/// extension the identifier reads a Mega Drive header from.
|
||||
/// </remarks>
|
||||
public static IReadOnlyCollection<string> KnownExtensions { get; } =
|
||||
Extensions.Keys.Select(key => key.TrimStart('.')).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static Classification Classify(string relativePath, ReadOnlySpan<byte> header, string? platformHint = null)
|
||||
{
|
||||
platformHint = CanonicalPlatform(platformHint);
|
||||
var ext = Path.GetExtension(relativePath);
|
||||
var evidence = new List<Evidence>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
MediaType? signatureType = null;
|
||||
string? signature = null;
|
||||
if (header.StartsWith("MZ"u8)) { signatureType = MediaType.WindowsPackage; signature = "PE/MZ"; }
|
||||
else if (header.StartsWith("PK\x03\x04"u8)) { signatureType = MediaType.Archive; signature = "ZIP"; }
|
||||
else if (header.StartsWith("NES\x1A"u8)) { signatureType = MediaType.Rom; signature = "iNES"; }
|
||||
else if (header.StartsWith("MComprHD"u8)) { signatureType = MediaType.DiscImage; signature = "CHD"; }
|
||||
else if (header.StartsWith("CISO"u8)) { signatureType = MediaType.DiscImage; signature = "PSP CSO"; }
|
||||
else if (header.StartsWith("\0PBP"u8)) { signatureType = MediaType.DiscImage; signature = "PlayStation PBP"; }
|
||||
else if (header.StartsWith("PFS0"u8)) { signatureType = MediaType.DiscImage; signature = "Switch PFS0"; }
|
||||
else if (header.Length >= 0x104 && header.Slice(0x100, 4).SequenceEqual("HEAD"u8)) { signatureType = MediaType.DiscImage; signature = "Switch XCI"; }
|
||||
else if (header.Length >= 8 && header.Slice(4, 4).SequenceEqual(new byte[] { 0x24, 0xFF, 0xAE, 0x51 })) { signatureType = MediaType.Rom; signature = "GBA"; }
|
||||
else if (header.Length >= 4 && header[..4].SequenceEqual(new byte[] { 0x80, 0x37, 0x12, 0x40 })) { signatureType = MediaType.Rom; signature = "N64 big-endian"; }
|
||||
else if (header.Length >= 4 && header[..4].SequenceEqual(new byte[] { 0x37, 0x80, 0x40, 0x12 })) { signatureType = MediaType.Rom; signature = "N64 byte-swapped"; }
|
||||
else if (header.Length >= 4 && header[..4].SequenceEqual(new byte[] { 0x40, 0x12, 0x37, 0x80 })) { signatureType = MediaType.Rom; signature = "N64 little-endian"; }
|
||||
// Nintendo optical media carry an exact magic word. Without it a bare .iso can only be told
|
||||
// apart by its directory name, which is what left GameCube and Wii games unresolvable.
|
||||
else if (header.Length >= 0x20 && BinaryPrimitives.ReadUInt32BigEndian(header.Slice(0x18, 4)) == 0x5D1C9EA3)
|
||||
{ signatureType = MediaType.DiscImage; signature = "Wii disc"; }
|
||||
else if (header.Length >= 0x20 && BinaryPrimitives.ReadUInt32BigEndian(header.Slice(0x1C, 4)) == 0xC2339F3D)
|
||||
{ signatureType = MediaType.DiscImage; signature = "GameCube disc"; }
|
||||
else if (header.Length >= 0x108 && header.Slice(0x100, 4).SequenceEqual("SEGA"u8)) { signatureType = MediaType.Rom; signature = "Sega cartridge"; }
|
||||
// Master System and Game Gear share the "TMR SEGA" header. Its offset depends on ROM size,
|
||||
// and the region nibble at +0x0F is what separates the two systems.
|
||||
else if (TryFindSegaEightBitHeader(header, out var segaEightBit)) { signatureType = MediaType.Rom; signature = segaEightBit; }
|
||||
else if (header.Length >= 0x150 && header.Slice(0x104, 4).SequenceEqual(new byte[] { 0xCE, 0xED, 0x66, 0x66 })) { signatureType = MediaType.Rom; signature = "Game Boy cartridge"; }
|
||||
else if (header.Length >= 0x160 && header.Slice(0xC0, 4).SequenceEqual(new byte[] { 0x24, 0xFF, 0xAE, 0x51 })) { signatureType = MediaType.Rom; signature = "Nintendo DS cartridge"; }
|
||||
else if (TryFindSnesHeader(header, out _)) { signatureType = MediaType.Rom; signature = "SNES cartridge"; }
|
||||
if (signature is not null) evidence.Add(new("signature", signature, "builtin", "1", Confidence.Deterministic, now));
|
||||
if (signature is not null) AddRomSemanticEvidence(signature, header, evidence, now);
|
||||
// MediaType.Rom is the default enum value, so a missing extension must be detected by the
|
||||
// lookup result rather than by comparing the media type against default. Conflating the two
|
||||
// classified every ROM extension without a matching header signature as Unknown.
|
||||
var knownExtension = Extensions.TryGetValue(ext, out var byExtension);
|
||||
if (!knownExtension &&
|
||||
(ext.Equals(".bin", StringComparison.OrdinalIgnoreCase) || ext.Equals(".img", StringComparison.OrdinalIgnoreCase)) &&
|
||||
platformHint is not null && DiscTrackPlatforms.Contains(platformHint))
|
||||
{
|
||||
byExtension = (MediaType.DiscImage, platformHint);
|
||||
knownExtension = true;
|
||||
}
|
||||
if (knownExtension) evidence.Add(new("extension", ext.ToLowerInvariant(), "builtin", "1", Confidence.Medium, now));
|
||||
var extensionConflictsWithDirectory = signatureType is null && platformHint is not null && byExtension.Platform is not null &&
|
||||
!platformHint.Equals(byExtension.Platform, StringComparison.OrdinalIgnoreCase);
|
||||
if (extensionConflictsWithDirectory)
|
||||
evidence.Add(new("platform.conflict", $"directory:{platformHint};extension:{byExtension.Platform}", "builtin", "1", Confidence.High, now));
|
||||
var type = signatureType ?? (!knownExtension || extensionConflictsWithDirectory ? MediaType.Unknown : byExtension.Type);
|
||||
var signaturePlatform = signature switch
|
||||
{
|
||||
"iNES" => "nes",
|
||||
"GBA" => "gba",
|
||||
"N64 big-endian" or "N64 byte-swapped" or "N64 little-endian" => "n64",
|
||||
"Sega cartridge" => SegaCartridgePlatform(header),
|
||||
"Sega Master System cartridge" => "mastersystem",
|
||||
"Sega Game Gear cartridge" => "gamegear",
|
||||
"GameCube disc" => "gamecube",
|
||||
"Wii disc" => "wii",
|
||||
"Game Boy cartridge" => ext.Equals(".gbc", StringComparison.OrdinalIgnoreCase) ? "gbc" : "gb",
|
||||
"Nintendo DS cartridge" => "nds",
|
||||
"SNES cartridge" => "snes",
|
||||
"PSP CSO" => "psp",
|
||||
"Switch PFS0" or "Switch XCI" => "switch",
|
||||
_ => null
|
||||
};
|
||||
var platform = signaturePlatform ?? platformHint ?? byExtension.Platform;
|
||||
if (signaturePlatform is not null && platformHint is not null &&
|
||||
!signaturePlatform.Equals(platformHint, StringComparison.OrdinalIgnoreCase))
|
||||
evidence.Add(new("platform.conflict", $"directory:{platformHint};signature:{signaturePlatform}",
|
||||
"builtin", "1", Confidence.High, now));
|
||||
if (platformHint is not null)
|
||||
evidence.Add(new("directory.platform", platformHint, "configured-path", "1",
|
||||
signaturePlatform is null ? Confidence.High : Confidence.Medium, now));
|
||||
var confidence = signatureType is not null ? Confidence.Deterministic
|
||||
: extensionConflictsWithDirectory ? Confidence.Low
|
||||
: !knownExtension ? Confidence.None : Confidence.Medium;
|
||||
return new(type, platform, confidence, evidence, type != MediaType.Unknown, $"classify:{ext.TrimStart('.').ToLowerInvariant()}");
|
||||
}
|
||||
|
||||
private static void AddRomSemanticEvidence(string signature, ReadOnlySpan<byte> header, List<Evidence> evidence, DateTimeOffset observedAt)
|
||||
{
|
||||
void Add(string kind, string value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value)) evidence.Add(new($"rom.{kind}", value, "builtin-header", "1", Confidence.Deterministic, observedAt));
|
||||
}
|
||||
switch (signature)
|
||||
{
|
||||
case "iNES" when header.Length >= 16:
|
||||
Add("mapper", (((header[7] & 0xF0) | (header[6] >> 4))).ToString(CultureInfo.InvariantCulture));
|
||||
Add("prgBytes", (header[4] * 16 * 1024).ToString(CultureInfo.InvariantCulture));
|
||||
Add("chrBytes", (header[5] * 8 * 1024).ToString(CultureInfo.InvariantCulture));
|
||||
Add("mirroring", (header[6] & 0x08) != 0 ? "four-screen" : (header[6] & 1) != 0 ? "vertical" : "horizontal");
|
||||
break;
|
||||
case "GBA" when header.Length >= 0xBD:
|
||||
Add("title", HeaderText(header.Slice(0xA0, 12))); Add("gameCode", HeaderText(header.Slice(0xAC, 4)));
|
||||
Add("makerCode", HeaderText(header.Slice(0xB0, 2))); Add("revision", header[0xBC].ToString(CultureInfo.InvariantCulture));
|
||||
Add("region", NintendoRegion(header[0xAF]));
|
||||
break;
|
||||
case "Nintendo DS cartridge" when header.Length >= 0x1F:
|
||||
Add("title", HeaderText(header[..12])); Add("gameCode", HeaderText(header.Slice(12, 4)));
|
||||
Add("makerCode", HeaderText(header.Slice(16, 2))); Add("unitCode", header[18].ToString(CultureInfo.InvariantCulture));
|
||||
Add("revision", header[30].ToString(CultureInfo.InvariantCulture)); Add("region", NintendoRegion(header[15]));
|
||||
break;
|
||||
case "Game Boy cartridge" when header.Length >= 0x150:
|
||||
Add("title", HeaderText(header.Slice(0x134, 15))); Add("makerCode", HeaderText(header.Slice(0x144, 2)));
|
||||
Add("cartridgeType", $"0x{header[0x147]:x2}"); Add("revision", header[0x14C].ToString(CultureInfo.InvariantCulture));
|
||||
Add("region", header[0x14A] == 0 ? "japan" : "world");
|
||||
break;
|
||||
case "N64 big-endian" when header.Length >= 0x40:
|
||||
Add("title", HeaderText(header.Slice(0x20, 20))); Add("gameCode", HeaderText(header.Slice(0x3B, 4)));
|
||||
Add("region", NintendoRegion(header[0x3E]));
|
||||
break;
|
||||
case "Sega cartridge" when header.Length >= 0x200:
|
||||
Add("console", HeaderText(header.Slice(0x100, 16)));
|
||||
Add("domesticTitle", HeaderText(header.Slice(0x120, 48))); Add("internationalTitle", HeaderText(header.Slice(0x150, 48)));
|
||||
Add("serial", HeaderText(header.Slice(0x180, 14))); Add("region", HeaderText(header.Slice(0x1F0, 3)));
|
||||
break;
|
||||
case "Sega Master System cartridge" or "Sega Game Gear cartridge"
|
||||
when TryFindSegaEightBitHeader(header, out _) && TryFindSegaEightBitOffset(header, out var segaOffset):
|
||||
// "TMR SEGA" is followed by a reserved word, a checksum, a five-digit BCD product
|
||||
// code whose top digit shares a byte with the version, and the region/size byte.
|
||||
Add("checksum", $"{header[segaOffset + 0x0B]:x2}{header[segaOffset + 0x0A]:x2}");
|
||||
Add("productCode", $"{header[segaOffset + 0x0E] >> 4:x1}{header[segaOffset + 0x0D]:x2}{header[segaOffset + 0x0C]:x2}");
|
||||
Add("revision", (header[segaOffset + 0x0E] & 0x0F).ToString(CultureInfo.InvariantCulture));
|
||||
Add("region", SegaEightBitRegion(header[segaOffset + 0x0F] >> 4));
|
||||
break;
|
||||
case "GameCube disc" or "Wii disc" when header.Length >= 0x500:
|
||||
Add("gameCode", HeaderText(header[..4]));
|
||||
Add("makerCode", HeaderText(header.Slice(4, 2)));
|
||||
Add("discNumber", header[6].ToString(CultureInfo.InvariantCulture));
|
||||
Add("revision", header[7].ToString(CultureInfo.InvariantCulture));
|
||||
Add("title", HeaderText(header.Slice(0x20, 64)));
|
||||
Add("region", NintendoDiscRegion(header[3]));
|
||||
break;
|
||||
case "SNES cartridge" when TryFindSnesHeader(header, out var offset):
|
||||
Add("title", HeaderText(header.Slice(offset, 21))); Add("mapMode", $"0x{header[offset + 0x15]:x2}");
|
||||
Add("cartridgeType", $"0x{header[offset + 0x16]:x2}"); Add("region", SnesRegion(header[offset + 0x19]));
|
||||
Add("makerCode", $"0x{header[offset + 0x1A]:x2}"); Add("revision", header[offset + 0x1B].ToString(CultureInfo.InvariantCulture));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Separate a Mega Drive/Genesis cartridge from a 32X or Sega CD dump. All three carry "SEGA"
|
||||
/// at 0x100; the rest of the console name field names the actual hardware.
|
||||
/// </summary>
|
||||
private static string SegaCartridgePlatform(ReadOnlySpan<byte> header)
|
||||
{
|
||||
if (header.Length < 0x110) return "genesis";
|
||||
var console = HeaderText(header.Slice(0x100, 16)).ToUpperInvariant();
|
||||
if (console.Contains("32X", StringComparison.Ordinal)) return "sega32x";
|
||||
if (console.Contains("SEGACD", StringComparison.Ordinal) ||
|
||||
console.Contains("MEGA CD", StringComparison.Ordinal)) return "segacd";
|
||||
return "genesis";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locate the "TMR SEGA" header an 8-bit Sega ROM places near the end of its first bank. The
|
||||
/// region nibble in the byte after the header separates Master System from Game Gear.
|
||||
/// </summary>
|
||||
private static bool TryFindSegaEightBitOffset(ReadOnlySpan<byte> header, out int offset)
|
||||
{
|
||||
foreach (var candidate in new[] { 0x7FF0, 0x3FF0, 0x1FF0 })
|
||||
if (candidate + 0x10 <= header.Length && header.Slice(candidate, 8).SequenceEqual("TMR SEGA"u8))
|
||||
{ offset = candidate; return true; }
|
||||
offset = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string SegaEightBitRegion(int region) => region switch
|
||||
{
|
||||
0x3 => "japan",
|
||||
0x4 => "export",
|
||||
0x5 => "japan",
|
||||
0x6 => "export",
|
||||
0x7 => "international",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
/// <summary>The fourth character of a GameCube or Wii game code is its region.</summary>
|
||||
private static string NintendoDiscRegion(byte code) => (char)code switch
|
||||
{
|
||||
'E' => "usa",
|
||||
'J' => "japan",
|
||||
'P' => "europe",
|
||||
'D' => "germany",
|
||||
'F' => "france",
|
||||
'I' => "italy",
|
||||
'S' => "spain",
|
||||
'K' or 'T' => "korea",
|
||||
'U' => "australia",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
private static bool TryFindSegaEightBitHeader(ReadOnlySpan<byte> header, out string signature)
|
||||
{
|
||||
foreach (var candidate in new[] { 0x7FF0, 0x3FF0, 0x1FF0 })
|
||||
{
|
||||
if (candidate + 0x10 > header.Length) continue;
|
||||
if (!header.Slice(candidate, 8).SequenceEqual("TMR SEGA"u8)) continue;
|
||||
var region = header[candidate + 0x0F] >> 4;
|
||||
signature = region is 0x5 or 0x6 or 0x7
|
||||
? "Sega Game Gear cartridge"
|
||||
: "Sega Master System cartridge";
|
||||
return true;
|
||||
}
|
||||
signature = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryFindSnesHeader(ReadOnlySpan<byte> header, out int offset)
|
||||
{
|
||||
foreach (var candidate in new[] { 0x7FC0, 0x81C0, 0xFFC0, 0x101C0 })
|
||||
{
|
||||
if (candidate + 0x20 > header.Length) continue;
|
||||
var mapMode = header[candidate + 0x15] & 0x3f;
|
||||
var complement = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(candidate + 0x1C, 2));
|
||||
var checksum = BinaryPrimitives.ReadUInt16LittleEndian(header.Slice(candidate + 0x1E, 2));
|
||||
if (mapMode is 0x20 or 0x21 or 0x22 or 0x23 or 0x25 or 0x30 or 0x31 or 0x32 or 0x35 &&
|
||||
checksum is not 0 and not 0xffff && (checksum ^ complement) == 0xffff)
|
||||
{ offset = candidate; return true; }
|
||||
}
|
||||
offset = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string HeaderText(ReadOnlySpan<byte> bytes) =>
|
||||
new string(Encoding.ASCII.GetString(bytes).Where(character => character is >= ' ' and not '\u007f').ToArray()).Trim(' ', '\xff');
|
||||
private static string NintendoRegion(byte code) => char.ToUpperInvariant((char)code) switch
|
||||
{
|
||||
'J' => "japan",
|
||||
'E' => "usa",
|
||||
'P' or 'D' or 'F' or 'I' or 'S' or 'H' or 'X' or 'Y' => "europe",
|
||||
'K' => "korea",
|
||||
'C' => "china",
|
||||
'A' => "world",
|
||||
_ => "unknown"
|
||||
};
|
||||
private static string SnesRegion(byte code) => code switch
|
||||
{
|
||||
0 => "japan",
|
||||
1 or 13 or 15 => "usa",
|
||||
2 or 3 or 6 or 7 or 8 or 9 or 10 or 11 => "europe",
|
||||
4 => "scandinavia",
|
||||
5 => "sweden",
|
||||
12 => "canada",
|
||||
14 => "korea",
|
||||
16 => "brazil",
|
||||
17 => "australia",
|
||||
_ => "unknown"
|
||||
};
|
||||
|
||||
public static string? InferPlatformHint(string libraryName, string libraryPath, string relativePath)
|
||||
{
|
||||
foreach (var candidate in new[] { Path.GetFileName(Path.TrimEndingDirectorySeparator(libraryPath)), libraryName })
|
||||
if (candidate is not null && PlatformDirectories.TryGetValue(NormalizePlatformDirectory(candidate), out var rootPlatform))
|
||||
return rootPlatform;
|
||||
|
||||
var segments = relativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0) return null;
|
||||
var platformIndex = segments[0].Equals("roms", StringComparison.OrdinalIgnoreCase) ? 1 : 0;
|
||||
return platformIndex < segments.Length && PlatformDirectories.TryGetValue(NormalizePlatformDirectory(segments[platformIndex]), out var platform)
|
||||
? platform : null;
|
||||
}
|
||||
|
||||
private static string NormalizePlatformDirectory(string value) => value.Trim().Replace("-Games", string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static async Task<HashResult> HashAsync(Stream stream, bool compatibilityHashes, CancellationToken cancellationToken)
|
||||
{
|
||||
using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
using var sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1);
|
||||
var crc = new Crc32();
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(1024 * 1024);
|
||||
long total = 0;
|
||||
try
|
||||
{
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
|
||||
{
|
||||
sha256.AppendData(buffer, 0, read);
|
||||
if (compatibilityHashes) { sha1.AppendData(buffer, 0, read); crc.Append(buffer.AsSpan(0, read)); }
|
||||
total += read;
|
||||
}
|
||||
return new(Convert.ToHexString(sha256.GetHashAndReset()).ToLowerInvariant(),
|
||||
compatibilityHashes ? Convert.ToHexString(sha1.GetHashAndReset()).ToLowerInvariant() : string.Empty,
|
||||
compatibilityHashes ? Convert.ToHexString(crc.GetCurrentHash()).ToLowerInvariant() : string.Empty, total);
|
||||
}
|
||||
finally { ArrayPool<byte>.Shared.Return(buffer); }
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> ParseCueReferences(string text) => CueFileRegex().Matches(text)
|
||||
.Select(m => m.Groups[1].Success ? m.Groups[1].Value : m.Groups[2].Value).Where(x => !string.IsNullOrWhiteSpace(x)).ToArray();
|
||||
|
||||
public static IReadOnlyList<string> ParseM3uReferences(string text) => text.Replace("\r", string.Empty, StringComparison.Ordinal)
|
||||
.Split('\n').Select(x => x.Trim()).Where(x => x.Length > 0 && !x.StartsWith('#')).ToArray();
|
||||
|
||||
public static bool IsSafeRelativeReference(string value) => !string.IsNullOrWhiteSpace(value) && value[0] is not '/' and not '\\' && !Path.IsPathFullyQualified(value) &&
|
||||
!value.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Any(x => x == "..");
|
||||
|
||||
[GeneratedRegex("(?im)^\\s*FILE\\s+(?:\"([^\"]+)\"|(\\S+))")]
|
||||
private static partial Regex CueFileRegex();
|
||||
}
|
||||
|
||||
public sealed record PeInspection(string Machine, string Kind, bool IsDll, bool IsManaged,
|
||||
bool HasResources, string? Subsystem, IReadOnlyDictionary<string, string> VersionInfo,
|
||||
Confidence Confidence, IReadOnlyList<Evidence> Evidence);
|
||||
|
||||
public static partial class WindowsPackageAnalysis
|
||||
{
|
||||
public static IReadOnlyDictionary<string, string> InspectMsiSummary(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path)) throw new ArgumentException("MSI path must be absolute.", nameof(path));
|
||||
using var root = RootStorage.OpenRead(path);
|
||||
using var summary = root.OpenStream("\u0005SummaryInformation");
|
||||
if (summary.Length > 1024 * 1024) throw new InvalidDataException("MSI summary information exceeds the configured limit.");
|
||||
var bytes = new byte[summary.Length];
|
||||
summary.ReadExactly(bytes);
|
||||
return ParsePropertySet(bytes);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParsePropertySet(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length < 48 || BinaryPrimitives.ReadUInt16LittleEndian(bytes) != 0xfffe)
|
||||
throw new InvalidDataException("MSI summary information has an invalid property-set header.");
|
||||
var sectionOffset = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(44, 4));
|
||||
if (sectionOffset < 0 || sectionOffset + 8 > bytes.Length) throw new InvalidDataException("MSI summary section is out of bounds.");
|
||||
var propertyCount = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(sectionOffset + 4, 4));
|
||||
if (propertyCount is < 0 or > 256 || sectionOffset + 8 + propertyCount * 8 > bytes.Length)
|
||||
throw new InvalidDataException("MSI summary property table exceeds its bounds.");
|
||||
var names = new Dictionary<int, string>
|
||||
{
|
||||
[2] = "Title",
|
||||
[3] = "Subject",
|
||||
[4] = "Author",
|
||||
[5] = "Keywords",
|
||||
[6] = "Comments",
|
||||
[7] = "Template",
|
||||
[8] = "LastSavedBy",
|
||||
[9] = "PackageCode",
|
||||
[18] = "CreatingApplication"
|
||||
};
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
for (var index = 0; index < propertyCount; index++)
|
||||
{
|
||||
var entry = sectionOffset + 8 + index * 8;
|
||||
var propertyId = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(entry, 4));
|
||||
if (!names.TryGetValue(propertyId, out var name)) continue;
|
||||
var valueOffset = sectionOffset + BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(entry + 4, 4));
|
||||
if (valueOffset < 0 || valueOffset + 8 > bytes.Length) continue;
|
||||
var type = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(valueOffset, 4));
|
||||
var length = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(valueOffset + 4, 4));
|
||||
if (length is <= 0 or > 65536) continue;
|
||||
string? value = type switch
|
||||
{
|
||||
30 when valueOffset + 8 + length <= bytes.Length => Encoding.Latin1.GetString(bytes.Slice(valueOffset + 8, length)).TrimEnd('\0'),
|
||||
31 when valueOffset + 8 + length * 2 <= bytes.Length => Encoding.Unicode.GetString(bytes.Slice(valueOffset + 8, length * 2)).TrimEnd('\0'),
|
||||
_ => null
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(value)) result[name] = value;
|
||||
}
|
||||
if (result.TryGetValue("Template", out var template))
|
||||
{
|
||||
var parts = template.Split(';', 2);
|
||||
if (parts[0].Length > 0) result["Architecture"] = parts[0];
|
||||
if (parts.Length > 1 && parts[1].Length > 0) result["Language"] = parts[1];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static PeInspection InspectPe(Stream stream)
|
||||
{
|
||||
if (!stream.CanSeek) throw new ArgumentException("PE inspection requires a bounded seekable stream.", nameof(stream));
|
||||
var original = stream.Position;
|
||||
try
|
||||
{
|
||||
using var reader = new PEReader(stream, PEStreamOptions.LeaveOpen);
|
||||
if (!reader.HasMetadata && reader.PEHeaders.PEHeader is null) throw new BadImageFormatException("Not a valid PE image.");
|
||||
var coff = reader.PEHeaders.CoffHeader;
|
||||
var pe = reader.PEHeaders.PEHeader;
|
||||
var machine = coff.Machine switch
|
||||
{
|
||||
Machine.I386 => "x86",
|
||||
Machine.Amd64 => "x64",
|
||||
Machine.Arm => "arm",
|
||||
Machine.Arm64 => "arm64",
|
||||
Machine.IA64 => "ia64",
|
||||
_ => $"unknown-0x{(ushort)coff.Machine:x4}"
|
||||
};
|
||||
var isDll = (coff.Characteristics & Characteristics.Dll) != 0;
|
||||
var evidence = new List<Evidence>
|
||||
{
|
||||
new("pe.machine", machine, "pe-reader", "1", Confidence.Deterministic, DateTimeOffset.UtcNow),
|
||||
new("pe.kind", isDll ? "library" : "executable", "pe-reader", "1", Confidence.Deterministic, DateTimeOffset.UtcNow)
|
||||
};
|
||||
if (pe is not null) evidence.Add(new("pe.subsystem", pe.Subsystem.ToString(), "pe-reader", "1", Confidence.Deterministic, DateTimeOffset.UtcNow));
|
||||
var versionInfo = ReadVersionInfo(reader);
|
||||
foreach (var (key, value) in versionInfo)
|
||||
evidence.Add(new($"pe.version.{key}", value, "pe-version-resource", "1", Confidence.Deterministic, DateTimeOffset.UtcNow));
|
||||
return new(machine, isDll ? "library" : "executable", isDll, reader.HasMetadata,
|
||||
pe?.ResourceTableDirectory.Size > 0, pe?.Subsystem.ToString(), versionInfo, Confidence.Deterministic, evidence);
|
||||
}
|
||||
finally { stream.Position = original; }
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadVersionInfo(PEReader reader)
|
||||
{
|
||||
const int maxResourceBytes = 4 * 1024 * 1024;
|
||||
var directory = reader.PEHeaders.PEHeader?.ResourceTableDirectory;
|
||||
if (directory is null || directory.Value.RelativeVirtualAddress == 0 || directory.Value.Size < 16) return new Dictionary<string, string>();
|
||||
try
|
||||
{
|
||||
var rootRva = directory.Value.RelativeVirtualAddress;
|
||||
var resource = reader.GetSectionData(rootRva).GetContent(0, Math.Min(directory.Value.Size, maxResourceBytes));
|
||||
var bytes = resource.AsSpan();
|
||||
var typeDirectory = FindDirectoryEntry(bytes, 0, 16);
|
||||
if (typeDirectory < 0) return new Dictionary<string, string>();
|
||||
var nameDirectory = FirstDirectoryChild(bytes, typeDirectory);
|
||||
var dataEntry = nameDirectory < 0 ? -1 : FirstDataChild(bytes, nameDirectory);
|
||||
if (dataEntry < 0 || dataEntry + 16 > bytes.Length) return new Dictionary<string, string>();
|
||||
var dataRva = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(dataEntry, 4));
|
||||
var size = BinaryPrimitives.ReadInt32LittleEndian(bytes.Slice(dataEntry + 4, 4));
|
||||
if (size <= 0 || size > maxResourceBytes) return new Dictionary<string, string>();
|
||||
var versionBytes = reader.GetSectionData(dataRva).GetContent(0, size).AsSpan();
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var key in new[] { "ProductName", "ProductVersion", "FileVersion", "CompanyName", "FileDescription", "InternalName", "OriginalFilename" })
|
||||
if (ReadVersionString(versionBytes, key) is { Length: > 0 } value) result[key] = value;
|
||||
return result;
|
||||
}
|
||||
catch (BadImageFormatException) { return new Dictionary<string, string>(); }
|
||||
catch (ArgumentOutOfRangeException) { return new Dictionary<string, string>(); }
|
||||
}
|
||||
|
||||
private static int FindDirectoryEntry(ReadOnlySpan<byte> bytes, int directoryOffset, int id)
|
||||
{
|
||||
if (directoryOffset < 0 || directoryOffset + 16 > bytes.Length) return -1;
|
||||
var count = BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(directoryOffset + 12, 2)) +
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(directoryOffset + 14, 2));
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var entry = directoryOffset + 16 + index * 8;
|
||||
if (entry + 8 > bytes.Length) return -1;
|
||||
var name = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(entry, 4));
|
||||
if ((name & 0x80000000) == 0 && name == id)
|
||||
return (int)(BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(entry + 4, 4)) & 0x7fffffff);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int FirstDirectoryChild(ReadOnlySpan<byte> bytes, int directoryOffset) => FirstChild(bytes, directoryOffset, true);
|
||||
private static int FirstDataChild(ReadOnlySpan<byte> bytes, int directoryOffset) => FirstChild(bytes, directoryOffset, false);
|
||||
private static int FirstChild(ReadOnlySpan<byte> bytes, int directoryOffset, bool directory)
|
||||
{
|
||||
if (directoryOffset < 0 || directoryOffset + 24 > bytes.Length) return -1;
|
||||
var count = BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(directoryOffset + 12, 2)) +
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(directoryOffset + 14, 2));
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var entry = directoryOffset + 16 + index * 8;
|
||||
if (entry + 8 > bytes.Length) return -1;
|
||||
var offset = BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(entry + 4, 4));
|
||||
if (((offset & 0x80000000) != 0) == directory) return (int)(offset & 0x7fffffff);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static string? ReadVersionString(ReadOnlySpan<byte> bytes, string key)
|
||||
{
|
||||
var keyBytes = Encoding.Unicode.GetBytes(key + '\0');
|
||||
var keyOffset = bytes.IndexOf(keyBytes);
|
||||
if (keyOffset < 6) return null;
|
||||
var valueCharacters = BinaryPrimitives.ReadUInt16LittleEndian(bytes.Slice(keyOffset - 4, 2));
|
||||
var valueOffset = (keyOffset + keyBytes.Length + 3) & ~3;
|
||||
var valueBytes = Math.Min(valueCharacters * 2, bytes.Length - valueOffset);
|
||||
if (valueOffset < 0 || valueBytes <= 0) return null;
|
||||
return Encoding.Unicode.GetString(bytes.Slice(valueOffset, valueBytes)).TrimEnd('\0').Trim();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<int> MissingNumberedPayloads(IEnumerable<string> fileNames)
|
||||
{
|
||||
var numbers = fileNames.Select(Path.GetFileName).Select(x => NumberedPayloadRegex().Match(x ?? string.Empty))
|
||||
.Where(x => x.Success).Select(x => int.Parse(x.Groups[1].Value, CultureInfo.InvariantCulture)).Distinct().Order().ToArray();
|
||||
if (numbers.Length < 2) return [];
|
||||
return Enumerable.Range(numbers[0], numbers[^1] - numbers[0] + 1).Except(numbers).ToArray();
|
||||
}
|
||||
|
||||
[GeneratedRegex("(?:^|[-_.])(\\d{1,4})\\.(?:bin|cab|dat)$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex NumberedPayloadRegex();
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Ludarium.Domain;
|
||||
using SharpCompress.Archives;
|
||||
using SharpCompress.Archives.SevenZip;
|
||||
using SharpCompress.Common;
|
||||
using SharpCompress.Readers;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public sealed record ArchiveLimits(int MaxMembers = 10_000, long MaxMetadataBytes = 4 * 1024 * 1024,
|
||||
long MaxExpandedBytes = 8L * 1024 * 1024 * 1024, decimal MaxExpansionRatio = 1_000,
|
||||
int MaxPathLength = 1_024);
|
||||
|
||||
public sealed record ArchiveInspection(IReadOnlyList<ArchiveMember> Members,
|
||||
IReadOnlyList<string> Findings, bool Complete);
|
||||
|
||||
public static class ArchiveAnalysis
|
||||
{
|
||||
public static ArchiveInspection InspectSevenZip(Guid artifactId, Stream stream, ArchiveLimits? limits = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
limits ??= new ArchiveLimits();
|
||||
if (!stream.CanRead || !stream.CanSeek) throw new ArgumentException("7z inspection requires a readable, seekable stream.", nameof(stream));
|
||||
stream.Position = 0;
|
||||
using var archive = SevenZipArchive.OpenArchive(stream, new ReaderOptions { LeaveStreamOpen = true });
|
||||
return InspectEntries(artifactId, archive.Entries.Select(entry => new MemberHeader(
|
||||
entry.Key ?? string.Empty, entry.CompressedSize, entry.Size, entry.IsDirectory)), limits, cancellationToken);
|
||||
}
|
||||
|
||||
public static ArchiveInspection InspectZip(Guid artifactId, Stream stream, ArchiveLimits? limits = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
limits ??= new ArchiveLimits();
|
||||
if (!stream.CanRead || !stream.CanSeek) throw new ArgumentException("ZIP inspection requires a readable, seekable stream.", nameof(stream));
|
||||
stream.Position = 0;
|
||||
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
return InspectEntries(artifactId, archive.Entries.Select(entry => new MemberHeader(
|
||||
entry.FullName, entry.CompressedLength, entry.Length, entry.FullName.EndsWith('/'))), limits, cancellationToken);
|
||||
}
|
||||
|
||||
private static ArchiveInspection InspectEntries(Guid artifactId, IEnumerable<MemberHeader> entries,
|
||||
ArchiveLimits limits, CancellationToken cancellationToken)
|
||||
{
|
||||
var members = new List<ArchiveMember>();
|
||||
var findings = new List<string>();
|
||||
long metadataBytes = 0;
|
||||
long expandedBytes = 0;
|
||||
long compressedBytes = 0;
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (members.Count >= limits.MaxMembers)
|
||||
{
|
||||
findings.Add($"Archive member limit of {limits.MaxMembers} was reached.");
|
||||
break;
|
||||
}
|
||||
|
||||
metadataBytes = checked(metadataBytes + Encoding.UTF8.GetByteCount(entry.Path));
|
||||
if (metadataBytes > limits.MaxMetadataBytes)
|
||||
{
|
||||
findings.Add("Archive metadata exceeds the configured byte limit.");
|
||||
break;
|
||||
}
|
||||
if (entry.Path.Length > limits.MaxPathLength)
|
||||
{
|
||||
findings.Add($"Archive member path exceeds {limits.MaxPathLength} characters.");
|
||||
continue;
|
||||
}
|
||||
|
||||
expandedBytes = checked(expandedBytes + entry.UncompressedSize);
|
||||
compressedBytes = checked(compressedBytes + entry.CompressedSize);
|
||||
if (expandedBytes > limits.MaxExpandedBytes)
|
||||
{
|
||||
findings.Add("Declared expanded archive size exceeds the configured limit.");
|
||||
break;
|
||||
}
|
||||
// Solid formats such as 7z may not expose a meaningful compressed size per member.
|
||||
// Apply the ratio gate only when the archive supplied that evidence; the absolute
|
||||
// expanded-size and member-count limits still bound header-only inspection.
|
||||
if (expandedBytes > 0 && compressedBytes > 0 && (decimal)expandedBytes / compressedBytes > limits.MaxExpansionRatio)
|
||||
{
|
||||
findings.Add("Declared archive expansion ratio exceeds the configured limit.");
|
||||
break;
|
||||
}
|
||||
|
||||
var normalized = entry.Path.Replace('\\', '/');
|
||||
var unsafePath = IsUnsafeMemberPath(normalized);
|
||||
if (unsafePath) findings.Add("Archive contains a member path that would escape an extraction root; it was recorded but never extracted.");
|
||||
var sequence = members.Count;
|
||||
var memberId = new Guid(SHA256.HashData(Encoding.UTF8.GetBytes($"{artifactId:N}|{sequence}|{normalized}"))[..16]);
|
||||
members.Add(new(memberId, artifactId, sequence, normalized, entry.CompressedSize, entry.UncompressedSize,
|
||||
entry.IsDirectory, unsafePath, null));
|
||||
}
|
||||
|
||||
return new(members, findings.Distinct(StringComparer.Ordinal).ToArray(), findings.Count == 0);
|
||||
}
|
||||
|
||||
private static bool IsUnsafeMemberPath(string path) =>
|
||||
Path.IsPathFullyQualified(path) || path.Split('/', StringSplitOptions.RemoveEmptyEntries).Any(x => x == "..");
|
||||
|
||||
private sealed record MemberHeader(string Path, long CompressedSize, long UncompressedSize, bool IsDirectory);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public static class ArtworkQuality
|
||||
{
|
||||
public const decimal VerifiedThreshold = 80m;
|
||||
|
||||
// Width / height of the platform's canonical retail front. Keep this list in canonical-id order
|
||||
// with PostgresStore.BuiltInPlatforms: the web stylesheet has a deterministic parity test that
|
||||
// compares both surfaces. Platforms without a stable physical format use an explicit 2:3
|
||||
// archival-poster profile instead of silently falling through to an unrelated case family.
|
||||
private static readonly Dictionary<string, decimal> RetailRatios =
|
||||
new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["windows"] = 5m / 7m,
|
||||
["dos"] = 4m / 5m,
|
||||
["linux"] = 2m / 3m,
|
||||
["macintosh"] = 4m / 5m,
|
||||
["amiga"] = 4m / 5m,
|
||||
["amiga-cd32"] = 1m / 1m,
|
||||
["c64"] = 4m / 5m,
|
||||
["zx-spectrum"] = 4m / 5m,
|
||||
["atari-st"] = 4m / 5m,
|
||||
["pc-98"] = 5m / 7m,
|
||||
["flash"] = 2m / 3m,
|
||||
["scummvm"] = 4m / 5m,
|
||||
["nes"] = 5m / 7m,
|
||||
["fds"] = 3m / 4m,
|
||||
["snes"] = 7m / 5m,
|
||||
["n64"] = 7m / 5m,
|
||||
["n64dd"] = 129m / 112m,
|
||||
["gamecube"] = 5m / 7m,
|
||||
["wii"] = 5m / 7m,
|
||||
["wii-u"] = 5m / 7m,
|
||||
["switch"] = 5m / 8m,
|
||||
["gb"] = 1m / 1m,
|
||||
["gbc"] = 1m / 1m,
|
||||
["gba"] = 1m / 1m,
|
||||
["nds"] = 135m / 122m,
|
||||
["3ds"] = 130m / 116m,
|
||||
["virtual-boy"] = 1m / 1m,
|
||||
["pokemon-mini"] = 1m / 1m,
|
||||
["psx"] = 1m / 1m,
|
||||
["ps2"] = 5m / 7m,
|
||||
["ps3"] = 27m / 34m,
|
||||
["ps4"] = 27m / 34m,
|
||||
["ps5"] = 27m / 34m,
|
||||
["psp"] = 3m / 5m,
|
||||
["psvita"] = 25m / 32m,
|
||||
["xbox"] = 5m / 7m,
|
||||
["xbox-360"] = 5m / 7m,
|
||||
["xbox-one"] = 27m / 34m,
|
||||
["xbox-series"] = 27m / 34m,
|
||||
["master-system"] = 5m / 7m,
|
||||
["genesis"] = 5m / 7m,
|
||||
["sega-cd"] = 5m / 7m,
|
||||
["sega-32x"] = 5m / 7m,
|
||||
["saturn"] = 5m / 7m,
|
||||
["dreamcast"] = 1m / 1m,
|
||||
["game-gear"] = 5m / 7m,
|
||||
["sg-1000"] = 5m / 7m,
|
||||
["atari-2600"] = 5m / 7m,
|
||||
["atari-5200"] = 5m / 7m,
|
||||
["atari-7800"] = 5m / 7m,
|
||||
["atari-jaguar"] = 5m / 7m,
|
||||
["atari-lynx"] = 13m / 16m,
|
||||
["neo-geo"] = 4m / 5m,
|
||||
["neo-geo-cd"] = 1m / 1m,
|
||||
["neo-geo-pocket"] = 7m / 8m,
|
||||
["neo-geo-pocket-color"] = 1m / 1m,
|
||||
["pc-engine"] = 1m / 1m,
|
||||
["pc-engine-cd"] = 1m / 1m,
|
||||
["pc-fx"] = 1m / 1m,
|
||||
["3do"] = 1m / 1m,
|
||||
["colecovision"] = 5m / 7m,
|
||||
["intellivision"] = 5m / 7m,
|
||||
["odyssey2"] = 5m / 7m,
|
||||
["vectrex"] = 5m / 7m,
|
||||
["wonderswan"] = 4m / 5m,
|
||||
["wonderswan-color"] = 2m / 3m,
|
||||
["arcade"] = 2m / 3m,
|
||||
["pico-8"] = 2m / 3m
|
||||
};
|
||||
|
||||
// Historical scan output predates the hyphenated ids in the built-in catalog. It remains
|
||||
// readable, but aliases do not inflate the canonical profile matrix or its coverage count.
|
||||
private static readonly Dictionary<string, string> RetailRatioAliases =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["wiiu"] = "wii-u",
|
||||
["xbox360"] = "xbox-360",
|
||||
["xboxone"] = "xbox-one",
|
||||
["xboxseries"] = "xbox-series",
|
||||
["mastersystem"] = "master-system",
|
||||
["gamegear"] = "game-gear",
|
||||
["sega32x"] = "sega-32x",
|
||||
["segacd"] = "sega-cd"
|
||||
};
|
||||
|
||||
public static IReadOnlyDictionary<string, decimal> RetailProfileRatios { get; } =
|
||||
new ReadOnlyDictionary<string, decimal>(RetailRatios);
|
||||
|
||||
public static decimal Score(int? width, int? height, decimal? ratio, string source, Confidence confidence,
|
||||
string? platform = null)
|
||||
{
|
||||
if (width is null || height is null || width <= 0 || height <= 0) return 0;
|
||||
// Steam library art is digital key art, not a photographed/scanned retail box front.
|
||||
// It remains useful provenance, but must never satisfy Ludarium's retail-cover contract.
|
||||
if (source.Equals("Steam", StringComparison.OrdinalIgnoreCase)) return 0;
|
||||
ratio ??= (decimal)width.Value / height.Value;
|
||||
var expected = source.Equals("SteamDigital", StringComparison.OrdinalIgnoreCase)
|
||||
? 2m / 3m
|
||||
: ExpectedRatio(platform);
|
||||
var deviation = Math.Abs(ratio.Value - expected) / expected;
|
||||
decimal score = 0;
|
||||
if (deviation <= 0.06m) score += 50;
|
||||
else if (deviation <= 0.12m) score += 20;
|
||||
else return source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase) ? 35 : 0;
|
||||
if (width >= 500) score += 20;
|
||||
else if (width >= 300) score += 10;
|
||||
if (height >= 700) score += 20;
|
||||
else if (height >= 450) score += 10;
|
||||
score += confidence switch { Confidence.Deterministic => 15, Confidence.High => 12, Confidence.Medium => 6, _ => 0 };
|
||||
if (source.Equals("Generated", StringComparison.OrdinalIgnoreCase)) score = Math.Min(score, 25);
|
||||
if (source.Equals("Library", StringComparison.OrdinalIgnoreCase) && deviation > 0.06m) score = Math.Min(score, 45);
|
||||
return Math.Min(score, 100);
|
||||
}
|
||||
|
||||
public static decimal ExpectedRatio(string? platform)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(platform)) return 2m / 3m;
|
||||
var id = platform.Trim();
|
||||
if (RetailRatioAliases.TryGetValue(id, out var canonical)) id = canonical;
|
||||
return RetailRatios.TryGetValue(id, out var ratio) ? ratio : 2m / 3m;
|
||||
}
|
||||
|
||||
public static bool MatchesRetailProfile(GameArtwork artwork, string? platform) =>
|
||||
!artwork.Source.Equals("Steam", StringComparison.OrdinalIgnoreCase) &&
|
||||
(artwork.Source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase) ||
|
||||
artwork.AspectRatio is { } ratio && Math.Abs(ratio - ExpectedRatio(platform)) / ExpectedRatio(platform) <= 0.12m);
|
||||
|
||||
public static GameArtwork EvaluateForPlatform(GameArtwork artwork, string? platform)
|
||||
{
|
||||
if (artwork.Source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase)) return artwork;
|
||||
var score = Score(artwork.Width, artwork.Height, artwork.AspectRatio, artwork.Source,
|
||||
artwork.Confidence, platform);
|
||||
var verified = score >= VerifiedThreshold;
|
||||
return artwork with
|
||||
{
|
||||
Platform = platform,
|
||||
QualityScore = score,
|
||||
Role = artwork.Source.Equals("SteamDigital", StringComparison.OrdinalIgnoreCase)
|
||||
? ArtworkRole.Poster
|
||||
: verified ? ArtworkRole.VerifiedBoxFront : ArtworkRole.BoxFront,
|
||||
VerificationStatus = verified
|
||||
? ArtworkVerificationStatus.AutomaticallyVerified
|
||||
: ArtworkVerificationStatus.NeedsReview
|
||||
};
|
||||
}
|
||||
|
||||
public static GameArtwork? SelectBest(IEnumerable<GameArtwork?> candidates) => candidates
|
||||
.Where(candidate => candidate is not null && candidate.VerificationStatus != ArtworkVerificationStatus.Rejected &&
|
||||
(candidate.QualityScore > 0 || candidate.Source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase)))
|
||||
.Cast<GameArtwork>()
|
||||
.OrderByDescending(candidate => candidate.QualityScore)
|
||||
.ThenByDescending(candidate => candidate.Confidence)
|
||||
.ThenByDescending(candidate => SourcePriority(candidate.Source))
|
||||
.ThenByDescending(candidate => candidate.RetrievedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
private static int SourcePriority(string source) => source.ToLowerInvariant() switch
|
||||
{
|
||||
"uploaded" => 5,
|
||||
"worldofgames" => 4,
|
||||
"launchbox" => 3,
|
||||
"libretro" => 2,
|
||||
"psxdatacenter" => 2,
|
||||
"steamdigital" => 1,
|
||||
"nintendo" => 1,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public sealed record BrowserGameDataBinding(Guid ArtifactId, long ArtifactVersion, string Core,
|
||||
string? ArtifactSha256);
|
||||
|
||||
public static class BrowserGameDataPolicy
|
||||
{
|
||||
public const string SourceType = "BrowserPlayerV1";
|
||||
public static IReadOnlyList<string> AutomaticRestoreCores { get; } = Array.AsReadOnly([
|
||||
"fceumm",
|
||||
"gambatte",
|
||||
"melonds",
|
||||
"mgba",
|
||||
"snes9x",
|
||||
"ppsspp",
|
||||
"pcsx_rearmed",
|
||||
"n64wasm",
|
||||
"genesis_plus_gx"
|
||||
]);
|
||||
|
||||
public static bool SupportsAutomaticRestore(string? core) =>
|
||||
core is not null && AutomaticRestoreCores.Contains(core, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static Guid StableEntryId(Guid gameId, string core, GameDataKind kind)
|
||||
{
|
||||
if (gameId == Guid.Empty || string.IsNullOrWhiteSpace(core))
|
||||
throw new ArgumentException("A game and emulator core are required for browser game data.");
|
||||
var input = Encoding.UTF8.GetBytes($"ludarium-browser-data:{gameId:D}:{core.Trim().ToLowerInvariant()}:{kind}");
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(input);
|
||||
return new Guid(hash.AsSpan(0, 16));
|
||||
}
|
||||
|
||||
public static string EncodeBinding(Guid artifactId, long artifactVersion, string core, string? artifactSha256)
|
||||
{
|
||||
if (!SupportsAutomaticRestore(core))
|
||||
throw new ArgumentException("Automatic restore is not fixture-verified for this core.", nameof(core));
|
||||
if (artifactId == Guid.Empty || artifactVersion < 1)
|
||||
throw new ArgumentException("A versioned artifact identity is required for automatic restore.");
|
||||
var binding = new BrowserGameDataBinding(artifactId, artifactVersion, core,
|
||||
NormalizeSha256(artifactSha256));
|
||||
return Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(binding));
|
||||
}
|
||||
|
||||
public static bool Matches(string? sourceType, string? sourceId, BrowserPlaySession session,
|
||||
Artifact artifact)
|
||||
{
|
||||
if (!string.Equals(sourceType, SourceType, StringComparison.Ordinal) || string.IsNullOrWhiteSpace(sourceId) ||
|
||||
sourceId.Length > 2048 || !SupportsAutomaticRestore(session.Core)) return false;
|
||||
try
|
||||
{
|
||||
var binding = JsonSerializer.Deserialize<BrowserGameDataBinding>(Convert.FromBase64String(sourceId));
|
||||
if (binding is null || binding.ArtifactId != session.ArtifactId || binding.ArtifactId != artifact.Id ||
|
||||
binding.ArtifactVersion != artifact.Version ||
|
||||
!string.Equals(binding.Core, session.Core, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
var expectedSha = NormalizeSha256(binding.ArtifactSha256);
|
||||
if (binding.ArtifactSha256 is not null && expectedSha is null) return false;
|
||||
return expectedSha is null || expectedSha.Equals(NormalizeSha256(artifact.Sha256), StringComparison.Ordinal);
|
||||
}
|
||||
catch (Exception exception) when (exception is FormatException or JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeSha256(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
return normalized.Length == 64 && normalized.All(Uri.IsHexDigit) ? normalized : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
/// <summary>
|
||||
/// One browser-playable platform.
|
||||
/// </summary>
|
||||
/// <param name="Extensions">Container formats a deterministic fixture has proven in the real core.</param>
|
||||
/// <param name="CandidateExtensions">
|
||||
/// Formats whose delivery path is implemented but not yet fixture-proven. They are recognised so the
|
||||
/// interface can explain them exactly, and they fail closed until their qualification pass has run.
|
||||
/// </param>
|
||||
public sealed record BrowserPlayPlatform(string Platform, string Core, bool DesktopRecommended,
|
||||
IReadOnlySet<string> Extensions, bool ReleaseQualified, long MaximumBytes = 512L * 1024 * 1024,
|
||||
IReadOnlySet<string>? CandidateExtensions = null)
|
||||
{
|
||||
public bool Accepts(string? extension) => extension is not null &&
|
||||
(Extensions.Contains(extension) || CandidateExtensions?.Contains(extension) == true);
|
||||
|
||||
public bool IsQualified(string? extension) => extension is not null && Extensions.Contains(extension);
|
||||
}
|
||||
|
||||
public static class BrowserPlayPolicy
|
||||
{
|
||||
public const long MaximumRomBytes = 512L * 1024 * 1024;
|
||||
|
||||
private static readonly Dictionary<string, BrowserPlayPlatform> Platforms =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// A platform may only become release-qualified after a deterministic ROM fixture has
|
||||
// completed the real bundled core in the browser. Keep future mappings here so they
|
||||
// remain explicit and fail closed until that evidence exists.
|
||||
["nes"] = new("nes", "fceumm", false, Set(".nes"), true),
|
||||
["snes"] = new("snes", "snes9x", false, Set(".sfc"), true,
|
||||
CandidateExtensions: Set(".smc")),
|
||||
["gb"] = new("gb", "gambatte", false, Set(".gb"), true),
|
||||
["gbc"] = new("gbc", "gambatte", false, Set(".gbc"), true),
|
||||
["gba"] = new("gba", "mgba", false, Set(".gba"), true),
|
||||
["n64"] = new("n64", "n64wasm", true, Set(".n64", ".z64", ".v64", ".zip"), true),
|
||||
["nds"] = new("nds", "melonds", true, Set(".nds"), true),
|
||||
// A CUE sheet stands for its whole disc set; Ludarium streams the sheet and its tracks
|
||||
// together as one archive, so the size limit applies to the resolved set, not to the sheet.
|
||||
// EmulatorJS mounts that archive, but the delivery has not completed a live fixture yet.
|
||||
["psx"] = new("psx", "pcsx_rearmed", true, Set(".chd"), true,
|
||||
4L * 1024 * 1024 * 1024, Set(".pbp", ".cue", ".m3u")),
|
||||
["psp"] = new("psp", "ppsspp", true, Set(".pbp"), true,
|
||||
CandidateExtensions: Set(".iso", ".cso")),
|
||||
// Play!.js range-reads the session content as a raw block device and boots it as an ISO,
|
||||
// so it cannot mount an archive: no CUE set or playlist is offered here. Only the ISO
|
||||
// fixture has ever completed the live gate, so every compressed container and the raw
|
||||
// BIN track stay candidates until one proves the runtime decompresses them itself.
|
||||
["ps2"] = new("ps2", "playjs", true, Set(".iso"), true,
|
||||
8L * 1024 * 1024 * 1024, Set(".cso", ".chd", ".isz", ".bin")),
|
||||
|
||||
// Sega hardware the bundled genesis_plus_gx core covers. Each completed the live core gate
|
||||
// on its own pinned fixture: the core booted, ran, produced a savestate and restored it
|
||||
// into a relaunched session. That is evidence for these fixtures, not a compatibility
|
||||
// claim for every cartridge ever pressed.
|
||||
["genesis"] = new("genesis", "genesis_plus_gx", false, Set(".md"), true,
|
||||
CandidateExtensions: Set(".gen", ".smd", ".bin")),
|
||||
["mastersystem"] = new("mastersystem", "genesis_plus_gx", false, Set(".sms"), true),
|
||||
["gamegear"] = new("gamegear", "genesis_plus_gx", false, Set(".gg"), true)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// EmulatorJS cores that must stay in the image because a mapped platform uses them. The image
|
||||
/// build deletes every other core, so this list and that build step must never drift apart.
|
||||
/// Ludarium's own players (Play!.js, N64Wasm) are not EmulatorJS cores and are excluded.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> RequiredCores { get; } =
|
||||
Platforms.Values.Select(platform => platform.Core)
|
||||
.Where(core => core is not "playjs" and not "n64wasm")
|
||||
.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();
|
||||
|
||||
public static IReadOnlyList<BrowserPlayPlatform> DescribePlatforms() =>
|
||||
Platforms.Values.OrderBy(platform => platform.Platform, StringComparer.Ordinal).ToArray();
|
||||
|
||||
public static BrowserPlayPlatform? GetPlatform(string? platform) =>
|
||||
platform is not null && Platforms.TryGetValue(platform, out var value) ? value : null;
|
||||
|
||||
public static BrowserPlayCapability Evaluate(BrowserPlayCandidate? candidate, bool configured,
|
||||
bool emulatorHealthy, DateTimeOffset now)
|
||||
{
|
||||
if (candidate is null)
|
||||
return new(Guid.Empty, false, BrowserPlayState.MissingRom, "No present ROM is linked to this game.", CheckedAt: now);
|
||||
var platform = GetPlatform(candidate.Platform);
|
||||
if (!configured)
|
||||
return Unavailable(candidate, BrowserPlayState.Disabled, "Browser play is not configured.", now, platform);
|
||||
if (!candidate.SourceReadOnly)
|
||||
return Unavailable(candidate, BrowserPlayState.Disabled, "The source library is not verified read-only.", now, platform);
|
||||
if (!emulatorHealthy)
|
||||
return Unavailable(candidate, BrowserPlayState.EmulatorUnavailable, "The isolated emulator service is unavailable.", now, platform);
|
||||
if (platform is null)
|
||||
return Unavailable(candidate, BrowserPlayState.UnsupportedPlatform, "This platform is not allowlisted for browser play.", now);
|
||||
if (!platform.ReleaseQualified)
|
||||
return Unavailable(candidate, BrowserPlayState.UnsupportedPlatform,
|
||||
"This browser-emulation core is bundled but is not release-qualified by a deterministic playback fixture.", now, platform);
|
||||
if (!IsSafeRelativePath(candidate.RelativePath))
|
||||
return Unavailable(candidate, BrowserPlayState.UnsupportedFormat, "The linked ROM path is not a safe relative source path.", now, platform);
|
||||
var extension = Path.GetExtension(candidate.RelativePath);
|
||||
if (!platform.Accepts(extension) || candidate.Size <= 0 || candidate.Size > platform.MaximumBytes)
|
||||
return Unavailable(candidate, BrowserPlayState.UnsupportedFormat, "This ROM format or size is not allowlisted for browser play.", now, platform);
|
||||
if (!platform.IsQualified(extension))
|
||||
return Unavailable(candidate, BrowserPlayState.UnsupportedFormat,
|
||||
"Ludarium can deliver this container format, but it is not release-qualified by a deterministic playback fixture.",
|
||||
now, platform);
|
||||
return new(candidate.GameId, true, BrowserPlayState.Available, "Ready to play in Ludarium's isolated browser player.",
|
||||
platform.Platform, EmulatorName(platform.Core), platform.Core, platform.DesktopRecommended, now,
|
||||
BrowserGameDataPolicy.SupportsAutomaticRestore(platform.Core));
|
||||
}
|
||||
|
||||
/// <summary>Every container format any platform accepts, qualified or not.</summary>
|
||||
public static IReadOnlySet<string> KnownExtensions { get; } = Platforms.Values
|
||||
.SelectMany(platform => platform.Extensions.Concat(platform.CandidateExtensions ?? new HashSet<string>()))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>A CUE sheet stands for a whole disc rather than for a single playable file.</summary>
|
||||
public static bool IsCueSheet(string? relativePath) => relativePath is not null &&
|
||||
Path.GetExtension(relativePath).Equals(".cue", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>An M3U playlist stands for a whole multi-disc game.</summary>
|
||||
public static bool IsPlaylist(string? relativePath) => relativePath is not null &&
|
||||
Path.GetExtension(relativePath).Equals(".m3u", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Choose the one artifact a browser session may stream.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Descriptors outrank what they describe, most general first: a playlist stands for the whole
|
||||
/// game, a CUE sheet for one disc, and the track files for neither. Without that order a
|
||||
/// multi-disc game looks ambiguous, because every disc and track is linked to the same game.
|
||||
/// Two playlists, two sheets, or two unrelated playable files stay deliberately unresolvable —
|
||||
/// Ludarium must never guess which disc to load.
|
||||
/// </remarks>
|
||||
public static BrowserPlayCandidate? SelectCandidate(IReadOnlyList<BrowserPlayCandidate> candidates)
|
||||
{
|
||||
var playlists = candidates.Where(candidate => IsPlaylist(candidate.RelativePath)).ToArray();
|
||||
if (playlists.Length > 0) return playlists.Length == 1 ? playlists[0] : null;
|
||||
var sheets = candidates.Where(candidate => IsCueSheet(candidate.RelativePath)).ToArray();
|
||||
if (sheets.Length > 0) return sheets.Length == 1 ? sheets[0] : null;
|
||||
return candidates.Count == 1 ? candidates[0] : null;
|
||||
}
|
||||
|
||||
public static string SafeFileName(string relativePath) => Path.GetFileName(relativePath.Replace('\\', '/'));
|
||||
|
||||
private static bool IsSafeRelativePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || path.StartsWith('/') || path.StartsWith('\\')
|
||||
|| Path.IsPathRooted(path) || path.Contains(':') || path.Contains('\0'))
|
||||
return false;
|
||||
var parts = path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length > 0 && parts.All(part => part is not "." and not "..");
|
||||
}
|
||||
|
||||
private static BrowserPlayCapability Unavailable(BrowserPlayCandidate candidate, BrowserPlayState state,
|
||||
string message, DateTimeOffset now, BrowserPlayPlatform? platform = null) =>
|
||||
new(candidate.GameId, false, state, message, candidate.Platform, EmulatorName(platform?.Core), platform?.Core,
|
||||
platform?.DesktopRecommended ?? false, now);
|
||||
|
||||
public static string EmulatorName(string? core) => core switch
|
||||
{
|
||||
"playjs" => "Ludarium Play!.js",
|
||||
"n64wasm" => "Ludarium N64Wasm",
|
||||
_ => "Ludarium EmulatorJS"
|
||||
};
|
||||
|
||||
private static HashSet<string> Set(params string[] values) => new(values, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public sealed class LogiqxCatalogImporter(ICatalogStore store) : ICatalogImporter
|
||||
{
|
||||
private const int BatchSize = 500;
|
||||
|
||||
public async Task<CatalogSource> ImportLogiqxAsync(string name, string version, Stream input, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Catalog name is required.", nameof(name));
|
||||
if (!input.CanRead) throw new ArgumentException("Catalog stream must be readable.", nameof(input));
|
||||
var sourceId = DeterministicGuid($"logiqx|{name.Trim()}|{version.Trim()}");
|
||||
var source = new CatalogSource(sourceId, name.Trim(), "LogiqxXml", version.Trim(), null, "Importing", DateTimeOffset.UtcNow, 0);
|
||||
await store.UpsertCatalogSourceAsync(source, cancellationToken);
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
await using var hashingStream = new CryptoStream(input, sha256, CryptoStreamMode.Read, leaveOpen: true);
|
||||
using var reader = XmlReader.Create(hashingStream, new XmlReaderSettings
|
||||
{
|
||||
Async = true,
|
||||
DtdProcessing = DtdProcessing.Prohibit,
|
||||
XmlResolver = null,
|
||||
MaxCharactersInDocument = 512L * 1024 * 1024,
|
||||
MaxCharactersFromEntities = 0,
|
||||
IgnoreComments = true,
|
||||
IgnoreProcessingInstructions = true
|
||||
});
|
||||
var batch = new List<CatalogEntry>(BatchSize);
|
||||
string? gameName = null;
|
||||
var count = 0;
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (reader.NodeType == XmlNodeType.Element && (reader.Name.Equals("game", StringComparison.OrdinalIgnoreCase) || reader.Name.Equals("machine", StringComparison.OrdinalIgnoreCase)))
|
||||
gameName = reader.GetAttribute("name");
|
||||
else if (reader.NodeType == XmlNodeType.EndElement && (reader.Name.Equals("game", StringComparison.OrdinalIgnoreCase) || reader.Name.Equals("machine", StringComparison.OrdinalIgnoreCase)))
|
||||
gameName = null;
|
||||
else if (reader.NodeType == XmlNodeType.Element && reader.Name.Equals("rom", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(gameName))
|
||||
{
|
||||
var romName = reader.GetAttribute("name") ?? "unnamed";
|
||||
var size = long.TryParse(reader.GetAttribute("size"), out var parsedSize) ? (long?)parsedSize : null;
|
||||
var sha = NormalizeHash(reader.GetAttribute("sha256"), 64);
|
||||
var sha1 = NormalizeHash(reader.GetAttribute("sha1"), 40);
|
||||
var crc = NormalizeHash(reader.GetAttribute("crc"), 8);
|
||||
if (sha is null && sha1 is null && crc is null) continue;
|
||||
var identity = $"{gameName}\n{romName}\n{size}\n{sha}\n{sha1}\n{crc}";
|
||||
batch.Add(new(DeterministicGuid($"{sourceId:N}|{identity}"), sourceId, gameName, romName, size, sha, sha1, crc, identity));
|
||||
count++;
|
||||
if (batch.Count >= BatchSize) { await store.UpsertCatalogEntriesAsync(batch, cancellationToken); batch.Clear(); }
|
||||
}
|
||||
}
|
||||
if (batch.Count > 0) await store.UpsertCatalogEntriesAsync(batch, cancellationToken);
|
||||
var digest = Convert.ToHexString(sha256.Hash ?? throw new InvalidDataException("Catalog checksum was not finalized.")).ToLowerInvariant();
|
||||
source = source with { Sha256 = digest, State = "Ready", EntryCount = count, ImportedAt = DateTimeOffset.UtcNow };
|
||||
await store.UpsertCatalogSourceAsync(source, cancellationToken);
|
||||
await store.MatchCatalogAsync(source.Id, cancellationToken);
|
||||
return source;
|
||||
}
|
||||
|
||||
private static string? NormalizeHash(string? value, int length)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
return normalized.Length == length && normalized.All(Uri.IsHexDigit) ? normalized : null;
|
||||
}
|
||||
|
||||
private static Guid DeterministicGuid(string value) => new(SHA256.HashData(Encoding.UTF8.GetBytes(value))[..16]);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
/// <summary>
|
||||
/// The single catalog visibility specification used by in-memory policy checks and
|
||||
/// PostgreSQL-backed catalog queries. Support content remains persisted as evidence,
|
||||
/// but is not presented as a game.
|
||||
/// </summary>
|
||||
public static partial class CatalogVisibility
|
||||
{
|
||||
public static bool IsVisibleTitle(string title) =>
|
||||
!string.IsNullOrWhiteSpace(title) && !ExcludedTitle().IsMatch(title.Trim());
|
||||
|
||||
public static string SqlPredicate(string alias = "g") =>
|
||||
$"NOT ({alias}.title ~* '^(steam|fmt-v[0-9].*|frosty[[:space:]]+mod[[:space:]]+tool.*|.*dlc[[:space:]]+unlocker.*)$')";
|
||||
|
||||
[GeneratedRegex(@"(?i)^(?:steam|fmt-v\d.*|frosty\s+mod\s+tool.*|.*dlc\s+unlocker.*)$")]
|
||||
private static partial Regex ExcludedTitle();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public static class ClaimPolicy
|
||||
{
|
||||
public static MetadataClaim? SelectEffective(IEnumerable<MetadataClaim> claims)
|
||||
{
|
||||
var materialized = claims.ToArray();
|
||||
var superseded = materialized.Where(x => x.SupersedesId is not null).Select(x => x.SupersedesId!.Value).ToHashSet();
|
||||
return materialized.Where(x => !superseded.Contains(x.Id))
|
||||
.OrderByDescending(x => x.ManualLock)
|
||||
.ThenByDescending(x => x.Confidence)
|
||||
.ThenByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public sealed class ConcurrencyException(string message) : Exception(message);
|
||||
public sealed class ResourceConflictException(string message) : Exception(message);
|
||||
public sealed class SnapshotPreconditionException(string message) : Exception(message);
|
||||
|
||||
public sealed record Page<T>(IReadOnlyList<T> Items, int PageNumber, int PageSize, long Total);
|
||||
public sealed record WishlistSummary(long Total, long HighPriority, long Upcoming, long PriceDrops, long Acquired);
|
||||
public sealed record GameDiscoveryResult(string Provider, string ExternalId, string Title, string? Platform,
|
||||
DateTimeOffset? ReleaseDate, string? ArtworkUrl, string? StoreUrl);
|
||||
public sealed record GameDiscoveryResponse(bool Configured, string Provider, IReadOnlyList<GameDiscoveryResult> Results, string? Message = null);
|
||||
public sealed record GameCollectionInput(string Name, string? Description, CollectionKind Kind,
|
||||
CollectionRule? Rule, bool Pinned);
|
||||
public sealed record GameUserStateInput(bool Favorite, GamePlayStatus Status, int? Rating,
|
||||
int? Difficulty, decimal? CompletionPercent, string? Notes, int PlayCount,
|
||||
DateTimeOffset? LastPlayedAt);
|
||||
public sealed record GameRelationInput(GameRelationKind Kind, string Title, Guid? RelatedGameId,
|
||||
Guid? ArtifactId);
|
||||
public sealed record GameMediaInput(GameMediaKind Kind, string Title, string? Location,
|
||||
string? Provider = null, string? ExternalId = null);
|
||||
public sealed record PlatformDefinitionInput(string Id, string Name, string Category,
|
||||
string[] Aliases, bool Enabled);
|
||||
public sealed record GameDataEntryInput(GameDataKind Kind, string Name, string? Emulator,
|
||||
string? Device, string? Notes);
|
||||
public sealed record GameDataMetadataInput(string Name, string? Emulator, string? Device,
|
||||
string? Notes);
|
||||
public sealed record MetadataFieldCandidate(string Field, string Value, string Provider,
|
||||
string ExternalId, Confidence Confidence, string SourceVersion);
|
||||
public sealed record ProviderArtworkCandidate(string Url, string Role, string Provider,
|
||||
string ExternalId, Confidence Confidence);
|
||||
public sealed record ProviderGameResult(string Provider, string ExternalId, string Title,
|
||||
string? Platform, IReadOnlyList<MetadataFieldCandidate> Fields,
|
||||
IReadOnlyList<ProviderArtworkCandidate> Artwork,
|
||||
GameAchievementProgress? AchievementProgress = null);
|
||||
public sealed record ProviderDescriptor(string Id, string Name, bool Configured, string State,
|
||||
IReadOnlyList<string> Capabilities, bool RequiresInternet, string AttributionUrl,
|
||||
string? Message = null, DateTimeOffset? LastSuccessfulSyncAt = null);
|
||||
public sealed record ProviderHubResult(int Games, int ProvidersQueried, int Matched,
|
||||
int ClaimsAdded, int ArtworkCandidates, int AchievementSnapshots, int ReviewItems, int Failed);
|
||||
public sealed record ProviderValidationResult(string ProviderId, string Provider, string State,
|
||||
bool Configured, bool Reachable, bool FixtureMatched, string Message, DateTimeOffset CheckedAt);
|
||||
public sealed record BrowserPlayLaunch(BrowserPlaySession Session, string Token);
|
||||
public sealed record BrowserPlayContent(Stream Content, string FileName, long Length, string? Sha256);
|
||||
public sealed record FirmwareContent(Stream Content, long Length, string Sha256);
|
||||
public sealed record FirmwareRequirement(string Platform, string Slot, FirmwareAssetKind Kind,
|
||||
string Label, string RuntimeState, bool BrowserDelivery, long MaximumBytes,
|
||||
IReadOnlySet<string> Extensions);
|
||||
public sealed record FirmwareStatus(FirmwareRequirement Requirement, FirmwareAsset? Asset);
|
||||
public sealed record AuthorizedBrowserPlaySource(BrowserPlaySession Session, ArtifactDetails Details);
|
||||
public sealed record PlatformSummary(string Platform, long Artifacts, long Bytes, long Games);
|
||||
public sealed record PlatformPlayabilitySummary(string Platform, long Supported, long Ready, long Blocked);
|
||||
public sealed record PlayabilitySummary(long Supported, long Ready, long Blocked, long Unsupported,
|
||||
IReadOnlyList<PlatformPlayabilitySummary> ByPlatform);
|
||||
public sealed record DashboardSummary(long Libraries, long Artifacts, long PresentArtifacts, long Games,
|
||||
long Releases, long Bundles, long TotalBytes, long HashedArtifacts, long RecognizedArtifacts,
|
||||
long UnknownArtifacts, long MissingArtifacts, long DuplicateArtifacts, long DuplicateBytes,
|
||||
long OpenReviews, long CriticalFindings, long ActiveScans, long RootsRequiringAttention,
|
||||
long ExcludedSupportGames = 0, long UnresolvedGameCandidates = 0,
|
||||
DateTimeOffset? LastFullScanAt = null, DateTimeOffset? LastIncrementalScanAt = null,
|
||||
bool CatalogImportedWithoutScan = false, long OpenReviewRecords = 0,
|
||||
long ArtworkNeedingReview = 0)
|
||||
{
|
||||
public decimal? ConfidencePercent => PresentArtifacts == 0 ? null
|
||||
: Math.Round((HashedArtifacts + RecognizedArtifacts) * 50m / PresentArtifacts, 1);
|
||||
}
|
||||
public sealed record ArtifactDetails(Artifact Artifact, LibraryRoot Library, IReadOnlyList<Evidence> Evidence,
|
||||
IReadOnlyList<ArchiveMember> ArchiveMembers, IReadOnlyList<Bundle> Bundles,
|
||||
IReadOnlyList<Finding> Findings, IReadOnlyList<Artifact> Duplicates);
|
||||
public sealed record NativeLaunchCandidate(Guid ArtifactId, string RelativePath, long Size, bool SourceReadOnly);
|
||||
public sealed record StorageSummary(long TotalBytes, long DuplicateBytes, IReadOnlyList<PlatformSummary> Platforms,
|
||||
IReadOnlyList<LibraryStorageSummary> Libraries);
|
||||
public sealed record LibraryStorageSummary(Guid LibraryId, string Name, long Artifacts, long Bytes, long Missing);
|
||||
public sealed record HealthSummary(long Critical, long Warning, long Notice, long Unknown, long Missing,
|
||||
long IncompleteBundles, IReadOnlyList<Finding> RecentFindings);
|
||||
public sealed record HashResult(string Sha256, string Sha1, string Crc32, long BytesRead);
|
||||
public sealed record Classification(MediaType MediaType, string? Platform, Confidence Confidence,
|
||||
IReadOnlyList<Evidence> Evidence, bool Supported, string Capability);
|
||||
public sealed record RootVerification(bool Exists, bool Readable, bool IsReadOnly, string Message, bool? HasEntries = null)
|
||||
{
|
||||
public bool Available => Exists && Readable;
|
||||
}
|
||||
public sealed record OperationalSettings(int ReviewRetentionDays, int AuditRetentionDays, int ExportRetentionDays,
|
||||
int DefaultPageSize, ScanMode DefaultScanMode, long Version, DateTimeOffset UpdatedAt)
|
||||
{
|
||||
public static OperationalSettings Defaults => new(180, 365, 30, 50, ScanMode.Quick, 1, DateTimeOffset.UtcNow);
|
||||
}
|
||||
public sealed record GameDeletionResult(Game Game, IReadOnlyList<GameMedia> Media,
|
||||
IReadOnlyList<GameDataRevision> DataRevisions);
|
||||
|
||||
public interface ILudariumStore
|
||||
{
|
||||
Task InitializeAsync(CancellationToken cancellationToken);
|
||||
Task<int> GetSchemaVersionAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<LibraryRoot>> ListLibrariesAsync(CancellationToken cancellationToken);
|
||||
Task<LibraryRoot?> GetLibraryAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<LibraryHealthSummary>> ListLibraryHealthAsync(CancellationToken cancellationToken);
|
||||
Task UpsertLibraryAsync(LibraryRoot root, CancellationToken cancellationToken);
|
||||
Task<ScanRun> CreateScanAsync(Guid libraryId, ScanMode mode, string? idempotencyKey, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ScanRun>> ListScansAsync(CancellationToken cancellationToken);
|
||||
Task<ScanRun?> GetScanAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task UpdateScanAsync(ScanRun scan, CancellationToken cancellationToken);
|
||||
Task UpsertArtifactAsync(Artifact artifact, ContentBlob? content, IReadOnlyList<Evidence> evidence, CancellationToken cancellationToken);
|
||||
Task MarkArtifactIgnoredAsync(Guid artifactId, long nextVersion, Evidence evidence, CancellationToken cancellationToken);
|
||||
Task<ArtifactOverride?> GetArtifactOverrideAsync(Guid artifactId, CancellationToken cancellationToken);
|
||||
Task<Artifact?> FindByPathAsync(Guid libraryId, string relativePath, CancellationToken cancellationToken);
|
||||
Task<Artifact?> FindByHashAsync(Guid libraryId, long size, string sha256, CancellationToken cancellationToken);
|
||||
Task<long> MarkMissingExceptAsync(Guid libraryId, IReadOnlySet<string> observedPaths, DateTimeOffset at, CancellationToken cancellationToken);
|
||||
Task<Page<Artifact>> SearchArtifactsAsync(string? query, int page, int pageSize, CancellationToken cancellationToken);
|
||||
Task<ArtifactDetails?> GetArtifactDetailsAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<DashboardSummary> GetDashboardSummaryAsync(CancellationToken cancellationToken);
|
||||
Task<PlayabilitySummary> GetPlayabilitySummaryAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new PlayabilitySummary(0, 0, 0, 0, []));
|
||||
Task<StorageSummary> GetStorageSummaryAsync(CancellationToken cancellationToken);
|
||||
Task<HealthSummary> GetHealthSummaryAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<Artifact>> GetDuplicatesAsync(CancellationToken cancellationToken);
|
||||
Task ReplaceArchiveMembersAsync(Guid artifactId, IReadOnlyList<ArchiveMember> members, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ArchiveMember>> ListArchiveMembersAsync(Guid artifactId, CancellationToken cancellationToken);
|
||||
Task AddBundleAsync(Bundle bundle, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<Bundle>> ListBundlesAsync(CancellationToken cancellationToken);
|
||||
Task AddReviewAsync(ReviewItem item, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ReviewItem>> ListReviewsAsync(CancellationToken cancellationToken);
|
||||
Task<Page<ReviewItem>> SearchReviewsAsync(ReviewState? state, string? reason, int page, int pageSize, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ReviewGroup>> ListReviewGroupsAsync(ReviewState state, CancellationToken cancellationToken);
|
||||
async Task<Page<ReviewGroup>> SearchReviewGroupsAsync(ReviewGroupQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var page = Math.Max(1, query.Page);
|
||||
var pageSize = Math.Clamp(query.PageSize, 1, 100);
|
||||
IEnumerable<ReviewGroup> groups = await ListReviewGroupsAsync(query.State, cancellationToken);
|
||||
if (query.Category?.Equals("actionable", StringComparison.OrdinalIgnoreCase) == true)
|
||||
groups = groups.Where(group => group.Category is not ("unknown" or "duplicates"));
|
||||
else if (!string.IsNullOrWhiteSpace(query.Category))
|
||||
groups = groups.Where(group => group.Category.Equals(query.Category, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var search = query.Search.Trim();
|
||||
groups = groups.Where(group => $"{group.Reason} {group.PathPattern} {group.RecommendedAction} {group.Library} {group.Platform}"
|
||||
.Contains(search, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(query.Library)) groups = groups.Where(group => group.Library == query.Library);
|
||||
if (!string.IsNullOrWhiteSpace(query.Platform)) groups = groups.Where(group => group.Platform == query.Platform);
|
||||
if (query.Severity is not null) groups = groups.Where(group => group.Severity == query.Severity);
|
||||
if (query.Since is not null) groups = groups.Where(group => group.NewestAt >= query.Since);
|
||||
groups = query.Order switch
|
||||
{
|
||||
ReviewGroupOrder.Impact => groups.OrderByDescending(group => group.EstimatedBytes).ThenByDescending(group => group.Count),
|
||||
ReviewGroupOrder.Newest => groups.OrderByDescending(group => group.NewestAt),
|
||||
_ => groups.OrderByDescending(group => group.Severity).ThenByDescending(group => group.EstimatedBytes).ThenByDescending(group => group.Count)
|
||||
};
|
||||
var materialized = groups.ToArray();
|
||||
return new(materialized.Skip((page - 1) * pageSize).Take(pageSize).ToArray(), page, pageSize, materialized.LongLength);
|
||||
}
|
||||
Task<ReviewOperation> ResolveReviewGroupAsync(IReadOnlyList<Guid> ids, int expectedCount, string resolution, string actor, CancellationToken cancellationToken);
|
||||
Task<ReviewOperation> DeferReviewGroupAsync(IReadOnlyList<Guid> ids, int expectedCount, string reason, string actor, CancellationToken cancellationToken);
|
||||
Task<ReviewOperation> ReopenReviewGroupAsync(IReadOnlyList<Guid> ids, int expectedCount, ReviewState sourceState, string reason, string actor, CancellationToken cancellationToken);
|
||||
Task<ReviewOperation> ApplyReviewGroupActionAsync(IReadOnlyList<Guid> ids, int expectedCount, ReviewBulkAction action, string? platform, string? pathPattern, string actor, CancellationToken cancellationToken);
|
||||
Task UndoReviewOperationAsync(Guid id, string actor, CancellationToken cancellationToken);
|
||||
Task<ReviewItem> SetReviewStateAsync(Guid id, ReviewState state, string actor, CancellationToken cancellationToken);
|
||||
Task<ManualResolution> ResolveReviewAsync(Guid id, string resolution, string actor, CancellationToken cancellationToken);
|
||||
Task UndoResolutionAsync(Guid id, string actor, CancellationToken cancellationToken);
|
||||
Task AddClaimAsync(MetadataClaim claim, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<MetadataClaim>> ListClaimsAsync(string entityType, Guid entityId, CancellationToken cancellationToken);
|
||||
Task<Game> CreateGameAsync(string title, string actor, CancellationToken cancellationToken);
|
||||
Task<GameDeletionResult> DeleteGameAsync(Guid id, long expectedVersion, string actor,
|
||||
CancellationToken cancellationToken);
|
||||
Task<Game> UpdateGameAsync(Guid id, string title, long expectedVersion, string actor, CancellationToken cancellationToken);
|
||||
Task<Game?> GetGameAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<Game>> ListGamesAsync(CancellationToken cancellationToken);
|
||||
Task<Page<Game>> SearchGamesAsync(GameQuery query, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Convenience overload for callers that only page through the whole catalog.</summary>
|
||||
Task<Page<Game>> SearchGamesAsync(string? query, string? platform, int page, int pageSize,
|
||||
CancellationToken cancellationToken) =>
|
||||
SearchGamesAsync(new GameQuery { Search = query, Platform = platform, Page = page, PageSize = pageSize },
|
||||
cancellationToken);
|
||||
Task<Page<WishlistItem>> SearchWishlistAsync(string? query, string? platform, WishlistPriority? priority, WishlistStatus? status, string? sort, int page, int pageSize, CancellationToken cancellationToken);
|
||||
Task<WishlistSummary> GetWishlistSummaryAsync(CancellationToken cancellationToken);
|
||||
Task<WishlistItem> CreateWishlistItemAsync(WishlistInput input, string actor, CancellationToken cancellationToken);
|
||||
Task<WishlistItem> UpdateWishlistItemAsync(Guid id, WishlistInput input, long expectedVersion, string actor, CancellationToken cancellationToken);
|
||||
Task DeleteWishlistItemAsync(Guid id, long expectedVersion, string actor, CancellationToken cancellationToken);
|
||||
Task<int> ReconcileWishlistAsync(string actor, CancellationToken cancellationToken);
|
||||
Task<int> SynchronizeDiscoveredGamesAsync(Guid libraryId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<PlatformSummary>> ListPlatformsAsync(CancellationToken cancellationToken);
|
||||
Task<Release> CreateReleaseAsync(Guid gameId, string title, string? platform, string? region, string? revision, string actor, CancellationToken cancellationToken);
|
||||
Task<Release> UpdateReleaseAsync(Guid id, string title, string? platform, string? region, string? revision, long expectedVersion, string actor, CancellationToken cancellationToken);
|
||||
Task DeleteReleaseAsync(Guid id, long expectedVersion, string actor, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<Release>> ListReleasesAsync(Guid? gameId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Releases for exactly the games named, so a browser page never loads the whole catalog.</summary>
|
||||
Task<IReadOnlyList<Release>> ListReleasesForGamesAsync(IReadOnlyList<Guid> gameIds,
|
||||
CancellationToken cancellationToken);
|
||||
Task<ArtifactDetails?> GetGameArtworkAsync(Guid gameId, CancellationToken cancellationToken);
|
||||
Task UpsertGameArtworkAsync(GameArtwork artwork, CancellationToken cancellationToken);
|
||||
Task<GameArtwork?> GetSelectedGameArtworkAsync(Guid gameId, CancellationToken cancellationToken,
|
||||
string? platform = null);
|
||||
Task<IReadOnlyList<GameArtwork>> ListGameArtworkAsync(Guid gameId, CancellationToken cancellationToken);
|
||||
Task SelectGameArtworkAsync(Guid gameId, Guid artworkId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<GameArtwork>> ListArtworkNeedingReviewAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ArtworkReviewItem>> ListArtworkReviewItemsAsync(CancellationToken cancellationToken);
|
||||
Task<BackgroundJob> EnqueueJobAsync(string kind, string payloadJson, string? idempotencyKey, CancellationToken cancellationToken);
|
||||
Task<BackgroundJob?> LeaseJobAsync(string worker, TimeSpan lease, CancellationToken cancellationToken);
|
||||
Task UpdateJobAsync(BackgroundJob job, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<BackgroundJob>> ListJobsAsync(CancellationToken cancellationToken);
|
||||
Task<BackgroundJob?> GetJobAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task RequestJobCancellationAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<BackgroundJob> RetryJobAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task UpsertProviderSnapshotAsync(ProviderSnapshot snapshot, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ProviderSnapshot>> ListProviderSnapshotsAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<Finding>> ListFindingsAsync(CancellationToken cancellationToken);
|
||||
Task AddFindingAsync(Finding finding, CancellationToken cancellationToken);
|
||||
Task<IntegritySnapshot> CreateSnapshotAsync(string name, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<IntegritySnapshot>> ListSnapshotsAsync(CancellationToken cancellationToken);
|
||||
Task<IntegritySnapshot?> GetSnapshotAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<OperationalSettings> GetOperationalSettingsAsync(CancellationToken cancellationToken);
|
||||
Task<OperationalSettings> SaveOperationalSettingsAsync(OperationalSettings settings, long expectedVersion, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IBrowserPlayStore
|
||||
{
|
||||
Task<BrowserPlayCandidate?> GetBrowserPlayCandidateAsync(Guid gameId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<NativeLaunchCandidate>> ListSwitchLaunchCandidatesAsync(Guid gameId, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<NativeLaunchCandidate>>([]);
|
||||
Task<IReadOnlyList<NativeLaunchCandidate>> ListDolphinLaunchCandidatesAsync(Guid gameId, string platform,
|
||||
CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<NativeLaunchCandidate>>([]);
|
||||
Task<int> CountActiveBrowserPlaySessionsAsync(DateTimeOffset now, CancellationToken cancellationToken);
|
||||
Task SaveBrowserPlaySessionAsync(BrowserPlaySession session, string actor, CancellationToken cancellationToken);
|
||||
Task<BrowserPlaySession?> GetBrowserPlaySessionAsync(Guid sessionId, CancellationToken cancellationToken);
|
||||
Task EndBrowserPlaySessionAsync(Guid sessionId, PlaySessionState state, string actor,
|
||||
string? errorCode, string? errorMessage, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IFirmwareStore
|
||||
{
|
||||
Task<IReadOnlyList<FirmwareAsset>> ListFirmwareAssetsAsync(CancellationToken cancellationToken);
|
||||
Task<FirmwareAsset?> GetFirmwareAssetAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<FirmwareAsset?> GetSelectedFirmwareAssetAsync(string platform, string slot, CancellationToken cancellationToken);
|
||||
Task SaveFirmwareAssetAsync(FirmwareAsset asset, string actor, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<FirmwareAsset>> DeleteFirmwareAssetAsync(Guid id, long expectedVersion, string actor,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IBrowserPlayService
|
||||
{
|
||||
Task<BrowserPlayCapability> GetCapabilityAsync(Guid gameId, CancellationToken cancellationToken);
|
||||
Task<BrowserPlayLaunch> StartAsync(Guid gameId, string actor, CancellationToken cancellationToken);
|
||||
Task<BrowserPlaySession?> GetSessionAsync(Guid sessionId, CancellationToken cancellationToken);
|
||||
Task<BrowserPlaySession?> AuthorizeAsync(Guid sessionId, string token, CancellationToken cancellationToken);
|
||||
Task<AuthorizedBrowserPlaySource?> AuthorizeSourceAsync(Guid sessionId, string token, CancellationToken cancellationToken);
|
||||
Task<BrowserPlayContent?> OpenContentAsync(Guid sessionId, string token, CancellationToken cancellationToken);
|
||||
Task<FirmwareContent?> OpenFirmwareAsync(Guid sessionId, string token, CancellationToken cancellationToken);
|
||||
Task CancelAsync(Guid sessionId, string actor, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IGameDiscoveryService
|
||||
{
|
||||
bool Configured { get; }
|
||||
Task<GameDiscoveryResponse> SearchAsync(string query, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface ILibraryExperienceStore
|
||||
{
|
||||
Task<IReadOnlyList<GameGroup>> ListCollectionsAsync(CancellationToken cancellationToken);
|
||||
Task<GameGroup?> GetCollectionAsync(Guid id, CancellationToken cancellationToken);
|
||||
Task<GameGroup> CreateCollectionAsync(GameCollectionInput input, string actor, CancellationToken cancellationToken);
|
||||
Task<GameGroup> UpdateCollectionAsync(Guid id, GameCollectionInput input, long expectedVersion, string actor, CancellationToken cancellationToken);
|
||||
Task DeleteCollectionAsync(Guid id, long expectedVersion, string actor, CancellationToken cancellationToken);
|
||||
Task AddCollectionGameAsync(Guid collectionId, Guid gameId, string actor, CancellationToken cancellationToken);
|
||||
Task RemoveCollectionGameAsync(Guid collectionId, Guid gameId, string actor, CancellationToken cancellationToken);
|
||||
Task<Page<Game>> SearchCollectionGamesAsync(Guid collectionId, int page, int pageSize, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<TagSummary>> ListTagsAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<GameTag>> ListGameTagsAsync(Guid gameId, CancellationToken cancellationToken);
|
||||
Task<GameTag> AddGameTagAsync(Guid gameId, string name, string actor, CancellationToken cancellationToken);
|
||||
Task DeleteGameTagAsync(Guid gameId, Guid tagId, string actor, CancellationToken cancellationToken);
|
||||
Task<GameUserState> GetGameUserStateAsync(Guid gameId, CancellationToken cancellationToken);
|
||||
Task<GameUserState> SaveGameUserStateAsync(Guid gameId, GameUserStateInput input, long expectedVersion, string actor, CancellationToken cancellationToken);
|
||||
Task<GameUserState> RecordGamePlayedAsync(Guid gameId, string actor, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<RecentlyPlayedGame>> ListRecentlyPlayedGamesAsync(int limit, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<GameRelation>> ListGameRelationsAsync(Guid gameId, CancellationToken cancellationToken);
|
||||
Task<GameRelation> AddGameRelationAsync(Guid gameId, GameRelationInput input, string actor, CancellationToken cancellationToken);
|
||||
Task DeleteGameRelationAsync(Guid gameId, Guid relationId, string actor, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<GameMedia>> ListGameMediaAsync(Guid gameId, CancellationToken cancellationToken);
|
||||
Task<GameMedia> AddExternalGameMediaAsync(Guid gameId, GameMediaInput input, string actor, CancellationToken cancellationToken);
|
||||
Task UpsertGameMediaAsync(GameMedia media, CancellationToken cancellationToken);
|
||||
Task<GameMedia?> GetGameMediaAsync(Guid mediaId, CancellationToken cancellationToken);
|
||||
Task DeleteGameMediaAsync(Guid gameId, Guid mediaId, string actor, CancellationToken cancellationToken);
|
||||
Task<GameAchievementProgress?> GetAchievementProgressAsync(Guid gameId, CancellationToken cancellationToken);
|
||||
Task UpsertAchievementProgressAsync(GameAchievementProgress progress, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<PlatformDefinition>> ListPlatformDefinitionsAsync(CancellationToken cancellationToken);
|
||||
Task<PlatformDefinition> CreatePlatformDefinitionAsync(PlatformDefinitionInput input, string actor, CancellationToken cancellationToken);
|
||||
Task<PlatformDefinition> UpdatePlatformDefinitionAsync(string id, PlatformDefinitionInput input, long expectedVersion, string actor, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IGameDataVaultStore
|
||||
{
|
||||
Task<IReadOnlyList<GameDataItem>> ListGameDataAsync(Guid gameId, CancellationToken cancellationToken);
|
||||
Task<GameDataItem?> GetGameDataAsync(Guid entryId, CancellationToken cancellationToken);
|
||||
Task<Page<GameDataRevision>> ListGameDataRevisionsAsync(Guid entryId, int page, int pageSize,
|
||||
CancellationToken cancellationToken);
|
||||
Task<GameDataRevision?> GetGameDataRevisionAsync(Guid revisionId, CancellationToken cancellationToken);
|
||||
Task<GameDataItem> SaveGameDataRevisionAsync(Guid gameId, Guid entryId, GameDataEntryInput input,
|
||||
long expectedVersion, GameDataRevision revision, string actor, CancellationToken cancellationToken);
|
||||
Task<GameDataItem> UpdateGameDataAsync(Guid gameId, Guid entryId, GameDataMetadataInput input,
|
||||
long expectedVersion, string actor, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<GameDataRevision>> DeleteGameDataAsync(Guid gameId, Guid entryId,
|
||||
long expectedVersion, string actor, CancellationToken cancellationToken);
|
||||
Task<GameDataSummary> GetGameDataSummaryAsync(Guid? gameId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<string>> ListGameDataRevisionLocationsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IGameMetadataProvider
|
||||
{
|
||||
ProviderDescriptor Describe();
|
||||
Task<ProviderGameResult?> EnrichAsync(Game game, Release? release, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IGameProviderHub
|
||||
{
|
||||
IReadOnlyList<ProviderDescriptor> DescribeProviders();
|
||||
Task<ProviderValidationResult> ValidateAsync(string providerId, CancellationToken cancellationToken);
|
||||
Task<ProviderHubResult> EnrichAsync(IReadOnlyList<Game> games, IReadOnlyList<Release> releases,
|
||||
CancellationToken cancellationToken, Func<int, int, Task>? progress = null);
|
||||
}
|
||||
|
||||
public interface ICatalogStore
|
||||
{
|
||||
Task UpsertCatalogSourceAsync(CatalogSource source, CancellationToken cancellationToken);
|
||||
Task UpsertCatalogEntriesAsync(IReadOnlyList<CatalogEntry> entries, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<CatalogSource>> ListCatalogSourcesAsync(CancellationToken cancellationToken);
|
||||
Task<int> MatchCatalogAsync(Guid sourceId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<MatchCandidate>> ListMatchCandidatesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IReadOnlyLibraryFileSystem
|
||||
{
|
||||
RootVerification Verify(string path);
|
||||
IAsyncEnumerable<FileObservation> EnumerateAsync(LibraryRoot root, CancellationToken cancellationToken);
|
||||
ValueTask<Stream> OpenReadAsync(LibraryRoot root, string relativePath, CancellationToken cancellationToken);
|
||||
string ResolveContainedPath(LibraryRoot root, string relativePath);
|
||||
}
|
||||
|
||||
public sealed record FileObservation(string RelativePath, long Size, DateTimeOffset ModifiedAt);
|
||||
|
||||
public interface IScanCoordinator
|
||||
{
|
||||
Task<ScanRun> RequestAsync(Guid libraryId, ScanMode mode, string? idempotencyKey, CancellationToken cancellationToken);
|
||||
Task ExecuteAsync(Guid scanId, CancellationToken cancellationToken);
|
||||
Task CancelAsync(Guid scanId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface ICatalogImporter
|
||||
{
|
||||
Task<CatalogSource> ImportLogiqxAsync(string name, string version, Stream input, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface ISnapshotComparer
|
||||
{
|
||||
IReadOnlyList<SnapshotChange> Compare(IntegritySnapshot before, IntegritySnapshot after);
|
||||
}
|
||||
|
||||
public sealed record SupportBundleResult(string FileName, DateTimeOffset CreatedAt, int LibraryCount, int ScanCount);
|
||||
public interface ISupportBundleService
|
||||
{
|
||||
Task<SupportBundleResult> CreateAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record InventoryExportResult(string FileName, string Format, long Count, DateTimeOffset CreatedAt);
|
||||
public interface IInventoryExportService
|
||||
{
|
||||
Task<InventoryExportResult> CreateAsync(string format, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record GameCatalogExportResult(string FileName, string Format, long Count, DateTimeOffset CreatedAt);
|
||||
public sealed record GameCatalogExportFile(Stream Content, string ContentType, long Length, string FileName);
|
||||
public interface IGameCatalogExportService
|
||||
{
|
||||
Task<GameCatalogExportResult> CreateAsync(string format, CancellationToken cancellationToken);
|
||||
Task<GameCatalogExportFile?> OpenAsync(string fileName, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public sealed record DiscImageInspection(string? Platform, Confidence Confidence,
|
||||
IReadOnlyList<Evidence> Evidence);
|
||||
|
||||
public static class DiscImageAnalysis
|
||||
{
|
||||
private const int SectorSize = 2048;
|
||||
private const long PrimaryVolumeDescriptorOffset = 16L * SectorSize;
|
||||
private const int MaximumDirectoryBytes = 64 * 1024;
|
||||
private const int MaximumSystemConfigBytes = 4 * 1024;
|
||||
|
||||
public static async Task<DiscImageInspection> InspectPbpAsync(Stream stream, CancellationToken ct)
|
||||
{
|
||||
const int headerLength = 40;
|
||||
if (!stream.CanSeek || stream.Length < headerLength) return Empty();
|
||||
var header = await ReadAtAsync(stream, 0, headerLength, ct);
|
||||
if (header.Length != headerLength || !header.AsSpan(0, 4).SequenceEqual("\0PBP"u8)) return Empty();
|
||||
|
||||
var offsets = new uint[8];
|
||||
for (var index = 0; index < offsets.Length; index++)
|
||||
offsets[index] = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(8 + index * 4, 4));
|
||||
if (offsets[0] < headerLength || offsets[^1] > stream.Length) return Empty();
|
||||
for (var index = 1; index < offsets.Length; index++)
|
||||
if (offsets[index] < offsets[index - 1]) return Empty();
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var evidence = new List<Evidence>
|
||||
{
|
||||
new("disc.signature", "PBP", "builtin-pbp", "1", Confidence.Deterministic, now)
|
||||
};
|
||||
var dataPspLength = offsets[7] - offsets[6];
|
||||
var dataPsp = dataPspLength >= 4 ? await ReadAtAsync(stream, offsets[6], 4, ct) : [];
|
||||
var dataPsarLength = stream.Length - offsets[7];
|
||||
var dataPsar = dataPsarLength >= 8
|
||||
? await ReadAtAsync(stream, offsets[7], (int)Math.Min(16, dataPsarLength), ct)
|
||||
: [];
|
||||
if (dataPsar.AsSpan().StartsWith("PSISOIMG"u8))
|
||||
{
|
||||
evidence.Add(new("disc.platform", "psx:PBP:PSISOIMG", "builtin-pbp", "1", Confidence.Deterministic, now));
|
||||
return new("psx", Confidence.Deterministic, evidence);
|
||||
}
|
||||
if (dataPsp.AsSpan().SequenceEqual(new byte[] { 0x7f, (byte)'E', (byte)'L', (byte)'F' }) && dataPsarLength == 0)
|
||||
{
|
||||
evidence.Add(new("disc.platform", "psp:PBP:ELF", "builtin-pbp", "1", Confidence.Deterministic, now));
|
||||
return new("psp", Confidence.Deterministic, evidence);
|
||||
}
|
||||
return new(null, Confidence.Deterministic, evidence);
|
||||
}
|
||||
|
||||
public static async Task<DiscImageInspection> InspectIsoAsync(Stream stream, CancellationToken ct)
|
||||
{
|
||||
if (!stream.CanSeek || stream.Length < PrimaryVolumeDescriptorOffset + SectorSize)
|
||||
return Empty();
|
||||
var descriptor = await ReadAtAsync(stream, PrimaryVolumeDescriptorOffset, SectorSize, ct);
|
||||
if (descriptor.Length != SectorSize || descriptor[0] != 1 ||
|
||||
!descriptor.AsSpan(1, 5).SequenceEqual("CD001"u8) || descriptor[6] != 1)
|
||||
return Empty();
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var evidence = new List<Evidence>
|
||||
{
|
||||
new("disc.signature", "ISO9660-CD001", "builtin-iso9660", "1", Confidence.Deterministic, now)
|
||||
};
|
||||
if (!TryDirectoryRecord(descriptor, 156, out var rootExtent, out var rootLength))
|
||||
return new(null, Confidence.Deterministic, evidence);
|
||||
var directoryLength = (int)Math.Min(rootLength, MaximumDirectoryBytes);
|
||||
var directory = await ReadAtAsync(stream, (long)rootExtent * SectorSize, directoryLength, ct);
|
||||
if (directory.Length == 0) return new(null, Confidence.Deterministic, evidence);
|
||||
|
||||
uint systemExtent = 0;
|
||||
uint systemLength = 0;
|
||||
var hasPspGame = false;
|
||||
var hasUmdData = false;
|
||||
for (var offset = 0; offset < directory.Length;)
|
||||
{
|
||||
var length = directory[offset];
|
||||
if (length == 0) { offset = ((offset / SectorSize) + 1) * SectorSize; continue; }
|
||||
if (offset + length > directory.Length || length < 34) break;
|
||||
var nameLength = directory[offset + 32];
|
||||
if (33 + nameLength <= length)
|
||||
{
|
||||
var name = Encoding.ASCII.GetString(directory, offset + 33, nameLength);
|
||||
if (name.Equals("SYSTEM.CNF;1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
systemExtent = BinaryPrimitives.ReadUInt32LittleEndian(directory.AsSpan(offset + 2, 4));
|
||||
systemLength = BinaryPrimitives.ReadUInt32LittleEndian(directory.AsSpan(offset + 10, 4));
|
||||
}
|
||||
else if (name.Equals("PSP_GAME", StringComparison.OrdinalIgnoreCase)) hasPspGame = true;
|
||||
else if (name.Equals("UMD_DATA.BIN;1", StringComparison.OrdinalIgnoreCase)) hasUmdData = true;
|
||||
}
|
||||
offset += length;
|
||||
}
|
||||
if (hasPspGame && hasUmdData)
|
||||
{
|
||||
evidence.Add(new("disc.platform", "psp:PSP_GAME+UMD_DATA.BIN", "builtin-iso9660", "1", Confidence.Deterministic, now));
|
||||
return new("psp", Confidence.Deterministic, evidence);
|
||||
}
|
||||
if (systemExtent == 0 || systemLength == 0) return new(null, Confidence.Deterministic, evidence);
|
||||
var config = await ReadAtAsync(stream, (long)systemExtent * SectorSize,
|
||||
(int)Math.Min(systemLength, MaximumSystemConfigBytes), ct);
|
||||
var text = Encoding.ASCII.GetString(config);
|
||||
if (text.Contains("BOOT2", StringComparison.OrdinalIgnoreCase) &&
|
||||
text.Contains("cdrom0:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
evidence.Add(new("disc.platform", "ps2:SYSTEM.CNF:BOOT2", "builtin-iso9660", "1", Confidence.Deterministic, now));
|
||||
return new("ps2", Confidence.Deterministic, evidence);
|
||||
}
|
||||
if (text.Contains("BOOT", StringComparison.OrdinalIgnoreCase) &&
|
||||
text.Contains("cdrom:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
evidence.Add(new("disc.platform", "psx:SYSTEM.CNF:BOOT", "builtin-iso9660", "1", Confidence.Deterministic, now));
|
||||
return new("psx", Confidence.Deterministic, evidence);
|
||||
}
|
||||
return new(null, Confidence.Deterministic, evidence);
|
||||
}
|
||||
|
||||
private static bool TryDirectoryRecord(byte[] bytes, int offset, out uint extent, out uint length)
|
||||
{
|
||||
extent = 0; length = 0;
|
||||
if (offset < 0 || offset + 34 > bytes.Length || bytes[offset] < 34) return false;
|
||||
extent = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset + 2, 4));
|
||||
length = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset + 10, 4));
|
||||
return extent > 0 && length > 0;
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadAtAsync(Stream stream, long offset, int length, CancellationToken ct)
|
||||
{
|
||||
if (offset < 0 || length <= 0 || offset > stream.Length || stream.Length - offset < length) return [];
|
||||
stream.Position = offset;
|
||||
var bytes = new byte[length];
|
||||
var total = 0;
|
||||
while (total < bytes.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(bytes.AsMemory(total), ct);
|
||||
if (read == 0) break;
|
||||
total += read;
|
||||
}
|
||||
return total == bytes.Length ? bytes : bytes[..total];
|
||||
}
|
||||
|
||||
private static DiscImageInspection Empty() => new(null, Confidence.None, []);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace Ludarium.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Selects the one unique read-only GameCube or Wii image a Dolphin session may launch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Dolphin serves two platforms from one mount, so a runtime path always names its platform
|
||||
/// directory. Everything else — path safety, prefix rebasing, capability vocabulary — is shared with
|
||||
/// every other native player.
|
||||
/// </remarks>
|
||||
public static class DolphinLaunchPolicy
|
||||
{
|
||||
public static NativeRemotePlayer Player => NativeRemotePlayerRegistry.Dolphin;
|
||||
|
||||
public static IReadOnlyList<string> SupportedPlatforms => Player.Platforms;
|
||||
|
||||
/// <summary>A disc image below this size is a stub, a companion file or a partial download.</summary>
|
||||
private const long MinimumImageBytes = 1024 * 1024;
|
||||
|
||||
public static NativeRemotePlayCapability EvaluateCapability(Guid gameId, string? platform, bool runtimeReady,
|
||||
IReadOnlyList<NativeLaunchCandidate> candidates, DateTimeOffset? checkedAt = null,
|
||||
bool vaultBacked = false)
|
||||
{
|
||||
var now = checkedAt ?? DateTimeOffset.UtcNow;
|
||||
if (!Player.Owns(platform))
|
||||
return Unavailable(NativeRemotePlayState.UnsupportedPlatform,
|
||||
"Only GameCube and Wii games can use the isolated Dolphin player.");
|
||||
if (!runtimeReady)
|
||||
return Unavailable(NativeRemotePlayState.RuntimeUnavailable,
|
||||
"The isolated Dolphin runtime or exact-title controller is not available.");
|
||||
if (candidates.Count == 0)
|
||||
return Unavailable(NativeRemotePlayState.MissingGame,
|
||||
"No linked Dolphin-compatible disc image was found for this game.");
|
||||
if (!candidates.Any(candidate => candidate.SourceReadOnly))
|
||||
return Unavailable(NativeRemotePlayState.SourceNotReadOnly,
|
||||
"The linked source is not mounted read-only, so playback is blocked.");
|
||||
if (SelectGame(candidates) is null)
|
||||
return Unavailable(NativeRemotePlayState.AmbiguousMapping,
|
||||
"Ludarium could not identify one unique read-only Dolphin-compatible image for this game.");
|
||||
return NativeRemotePlayPolicy.Available(Player, gameId,
|
||||
"The exact linked game is ready in the isolated Dolphin player.", now, platform, vaultBacked);
|
||||
|
||||
NativeRemotePlayCapability Unavailable(NativeRemotePlayState state, string message) =>
|
||||
NativeRemotePlayPolicy.Unavailable(Player, gameId, state, message, now, platform);
|
||||
}
|
||||
|
||||
public static NativeLaunchCandidate? SelectGame(IReadOnlyList<NativeLaunchCandidate> candidates)
|
||||
{
|
||||
var eligible = candidates.Where(candidate => candidate.SourceReadOnly &&
|
||||
candidate.Size > MinimumImageBytes && Player.AcceptsExtension(candidate.RelativePath) &&
|
||||
NativeRemotePlayPolicy.IsSafeRelativePath(candidate.RelativePath)).ToArray();
|
||||
return eligible.Length == 1 ? eligible[0] : null;
|
||||
}
|
||||
|
||||
public static string ToRuntimePath(string platform, string relativePath, string catalogPrefix)
|
||||
{
|
||||
if (!Player.Owns(platform)) throw new InvalidOperationException("Unsupported Dolphin platform.");
|
||||
return NativeRemotePlayPolicy.ToRuntimePath(relativePath, catalogPrefix, Player.Extensions, platform);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public static class FirmwarePolicy
|
||||
{
|
||||
private static readonly Dictionary<string, FirmwareRequirement> Requirements = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["psx/bios"] = Requirement("psx", "bios", FirmwareAssetKind.Bios, "PlayStation BIOS",
|
||||
"Release-qualified browser runtime; personal BIOS optional", true, 4L * 1024 * 1024, ".bin", ".rom"),
|
||||
["ps2/bios"] = Requirement("ps2", "bios", FirmwareAssetKind.Bios, "PlayStation 2 BIOS",
|
||||
"Play!.js browser runtime uses its built-in HLE BIOS; retained for future native compatibility", false,
|
||||
16L * 1024 * 1024, ".bin", ".rom"),
|
||||
["switch/prod-keys"] = Requirement("switch", "prod-keys", FirmwareAssetKind.KeySet, "Switch production keys",
|
||||
"Provisioned to the isolated Eden runtime when its optional deployment profile is enabled", false, 4L * 1024 * 1024, ".keys", ".txt"),
|
||||
["switch/title-keys"] = Requirement("switch", "title-keys", FirmwareAssetKind.KeySet, "Switch title keys",
|
||||
"Provisioned to the isolated Eden runtime when its optional deployment profile is enabled", false, 4L * 1024 * 1024, ".keys", ".txt")
|
||||
};
|
||||
|
||||
public static IReadOnlyList<FirmwareRequirement> Describe() => Requirements.Values
|
||||
.OrderBy(value => value.Platform, StringComparer.Ordinal).ThenBy(value => value.Slot, StringComparer.Ordinal).ToArray();
|
||||
|
||||
public static FirmwareRequirement Get(string platform, string slot)
|
||||
{
|
||||
var key = $"{Normalize(platform)}/{Normalize(slot)}";
|
||||
return Requirements.TryGetValue(key, out var requirement)
|
||||
? requirement
|
||||
: throw new FirmwareValidationException("This firmware slot is not allowlisted by Ludarium.");
|
||||
}
|
||||
|
||||
public static string ValidateFileName(FirmwareRequirement requirement, string fileName)
|
||||
{
|
||||
var safe = Path.GetFileName(fileName?.Trim());
|
||||
if (string.IsNullOrWhiteSpace(safe) || safe.Length > 180 || safe.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
|
||||
throw new FirmwareValidationException("A safe firmware file name is required.");
|
||||
if (!requirement.Extensions.Contains(Path.GetExtension(safe)))
|
||||
throw new FirmwareValidationException($"{requirement.Label} accepts only {string.Join(", ", requirement.Extensions.Order())} files.");
|
||||
return safe;
|
||||
}
|
||||
|
||||
private static string Normalize(string value) => value.Trim().ToLowerInvariant();
|
||||
private static FirmwareRequirement Requirement(string platform, string slot, FirmwareAssetKind kind,
|
||||
string label, string runtimeState, bool browserDelivery, long maximumBytes, params string[] extensions) =>
|
||||
new(platform, slot, kind, label, runtimeState, browserDelivery, maximumBytes,
|
||||
new HashSet<string>(extensions, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public sealed class FirmwareValidationException(string message) : Exception(message);
|
||||
public sealed class FirmwareIntegrityException(string message) : Exception(message);
|
||||
@@ -0,0 +1,49 @@
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public static class GameDataVaultPolicy
|
||||
{
|
||||
private static readonly HashSet<string> ExecutableExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".exe", ".dll", ".com", ".scr", ".msi", ".bat", ".cmd", ".ps1", ".psm1",
|
||||
".vbs", ".vbe", ".js", ".jse", ".wsf", ".wsh", ".sh", ".appimage"
|
||||
};
|
||||
|
||||
public static GameDataEntryInput Normalize(GameDataEntryInput input)
|
||||
{
|
||||
if (!Enum.IsDefined(input.Kind)) throw new ArgumentException("Game-data kind is not supported.", nameof(input));
|
||||
return new(input.Kind,
|
||||
CleanRequired(input.Name, "Vault entry name", 120),
|
||||
Clean(input.Emulator, "Emulator", 80),
|
||||
Clean(input.Device, "Device", 80),
|
||||
Clean(input.Notes, "Notes", 1000));
|
||||
}
|
||||
|
||||
public static GameDataMetadataInput Normalize(GameDataMetadataInput input) => new(
|
||||
CleanRequired(input.Name, "Vault entry name", 120),
|
||||
Clean(input.Emulator, "Emulator", 80),
|
||||
Clean(input.Device, "Device", 80),
|
||||
Clean(input.Notes, "Notes", 1000));
|
||||
|
||||
public static string ValidateFileName(string fileName)
|
||||
{
|
||||
var cleaned = CleanRequired(fileName, "Filename", 160);
|
||||
if (cleaned.IndexOfAny(['/', '\\']) >= 0 || cleaned.Any(char.IsControl))
|
||||
throw new ArgumentException("Filename must be a single safe file name.", nameof(fileName));
|
||||
if (cleaned is "." or "..") throw new ArgumentException("Filename is not valid.", nameof(fileName));
|
||||
if (ExecutableExtensions.Contains(Path.GetExtension(cleaned)))
|
||||
throw new ArgumentException("Executable and script files are not accepted by the game-data vault.", nameof(fileName));
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private static string CleanRequired(string? value, string label, int maximum) =>
|
||||
Clean(value, label, maximum) ?? throw new ArgumentException($"{label} is required.");
|
||||
|
||||
private static string? Clean(string? value, string label, int maximum)
|
||||
{
|
||||
var cleaned = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
if (cleaned?.Length > maximum) throw new ArgumentException($"{label} cannot exceed {maximum} characters.");
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Everything the library browser can ask of the catalog.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Filtering and ordering belong to the query, not to the page the browser happens to hold. The
|
||||
/// library previously sorted the forty-eight games already on screen, which reordered a page rather
|
||||
/// than the collection and quietly disagreed with what the operator asked for.
|
||||
/// </remarks>
|
||||
public sealed record GameQuery
|
||||
{
|
||||
public string? Search { get; init; }
|
||||
public string? Platform { get; init; }
|
||||
|
||||
/// <summary>Ordering key. Unknown values fall back to title.</summary>
|
||||
public string? Sort { get; init; }
|
||||
|
||||
public bool? Favorite { get; init; }
|
||||
public GamePlayStatus? Status { get; init; }
|
||||
public Guid? CollectionId { get; init; }
|
||||
|
||||
/// <summary>Normalized tag name, as stored by <see cref="LibraryExperiencePolicy.NormalizeTag"/>.</summary>
|
||||
public string? Tag { get; init; }
|
||||
|
||||
/// <summary>Keep only games rated at least this highly.</summary>
|
||||
public int? MinimumRating { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Keep only games with a linked, present copy in a format some player accepts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a property of the collection, not of the runtime: a sidecar that is switched off does
|
||||
/// not make a game disappear from the library. Whether a specific game can start right now stays
|
||||
/// the capability's answer, shown on the game itself.
|
||||
/// </remarks>
|
||||
public bool PlayableOnly { get; init; }
|
||||
|
||||
public int Page { get; init; } = 1;
|
||||
public int PageSize { get; init; } = 50;
|
||||
|
||||
public static readonly IReadOnlyList<string> SortKeys =
|
||||
["title", "recent", "updated", "played", "rating"];
|
||||
|
||||
public bool HasFilters => !string.IsNullOrWhiteSpace(Search) || !string.IsNullOrWhiteSpace(Platform) ||
|
||||
Favorite is not null || Status is not null || CollectionId is not null ||
|
||||
!string.IsNullOrWhiteSpace(Tag) || MinimumRating is not null || PlayableOnly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The exact platform-and-container pairs some Ludarium player accepts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Derived from the browser matrix and the native registry so the library's "playable copy" filter
|
||||
/// can never claim a format no player takes, and can never drift from the policies that decide it.
|
||||
/// </remarks>
|
||||
public static class PlayableFormats
|
||||
{
|
||||
/// <summary>Each entry is <c>platform|.extension</c>, lower-cased.</summary>
|
||||
public static IReadOnlyList<string> Pairs { get; } = BuildPairs();
|
||||
|
||||
private static string[] BuildPairs()
|
||||
{
|
||||
var pairs = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var platform in BrowserPlayPolicy.DescribePlatforms())
|
||||
foreach (var extension in platform.Extensions.Concat(platform.CandidateExtensions ?? new HashSet<string>()))
|
||||
pairs.Add($"{platform.Platform.ToLowerInvariant()}|{extension.ToLowerInvariant()}");
|
||||
foreach (var player in NativeRemotePlayerRegistry.All)
|
||||
foreach (var platform in player.Platforms)
|
||||
foreach (var extension in player.Extensions)
|
||||
pairs.Add($"{platform.ToLowerInvariant()}|{extension.ToLowerInvariant()}");
|
||||
return [.. pairs];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public sealed record HealthRuleResult(string Category, Severity Severity, string Message);
|
||||
|
||||
public static class HealthRules
|
||||
{
|
||||
public static HealthRuleResult? EvaluateRoot(LibraryRoot root) =>
|
||||
!root.IsAvailable ? new("Availability", Severity.Critical, "The library root could not be read; existing artifacts were preserved.") :
|
||||
root.IsReadOnly != true ? new("Availability", Severity.Warning, "The library root is readable but a read-only mount could not be confirmed.") : null;
|
||||
|
||||
public static HealthRuleResult? EvaluateArtifact(Artifact artifact) => artifact.MediaType switch
|
||||
{
|
||||
MediaType.Unknown => new("Identity", Severity.Notice, "Content type is unknown and remains available for review."),
|
||||
_ when artifact.State == ArtifactState.Missing => new("Availability", Severity.Warning, "The artifact was not observed during a successful root traversal."),
|
||||
_ => null
|
||||
};
|
||||
|
||||
public static HealthRuleResult? EvaluateBundle(Bundle bundle) => bundle.State switch
|
||||
{
|
||||
BundleState.Incomplete => new("BundleCompleteness", Severity.Warning, "One or more required bundle members are missing."),
|
||||
BundleState.Ambiguous => new("BundleCompleteness", Severity.Notice, "Bundle membership requires an operator decision."),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public sealed record NonGamePath(string Category, string Evidence);
|
||||
|
||||
public static partial class LibraryContentPolicy
|
||||
{
|
||||
private static readonly HashSet<string> NonGameSegments = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"tools", "tool", "emulators", "emulator", "retroarch", "bios", "firmware",
|
||||
".gameyfin", ".cache", "cache", "$recycle.bin", "system volume information",
|
||||
"runtime", "_original_files", "sce_sys", "sce_module", "fakelib", "md5",
|
||||
"saves", "savegames", "screenshots", "manuals", "manual", "documentation", "docs",
|
||||
"patches", "patch", "updates", "update", "trainers", "trainer", "cheats", "keys",
|
||||
"shaders", "plugins", "logs", "covers", "cover", "artwork", "images", "imgs",
|
||||
"steam", "steamapps", "depotcache", "controller_base", "crack", "redist", "redistributables",
|
||||
"directx", "dotnet", "vcredist", "support", "extras", "bonus", "soundtrack"
|
||||
};
|
||||
private static readonly HashSet<string> GameExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".nes", ".sfc", ".smc", ".n64", ".z64", ".v64", ".gb", ".gbc", ".gba", ".nds", ".3ds",
|
||||
".cia", ".iso", ".cue", ".m3u", ".chd", ".cso", ".isz", ".pbp", ".xci", ".nsp", ".wbfs", ".rvz",
|
||||
".gcm", ".gcz", ".wia", ".gdi", ".cdi", ".vpk",
|
||||
".md", ".gen", ".smd", ".32x", ".sms", ".gg", ".sg",
|
||||
".wad", ".wux", ".wud", ".wua", ".exe", ".msi", ".zip", ".7z"
|
||||
};
|
||||
|
||||
public static NonGamePath? IdentifyNonGamePath(string relativePath)
|
||||
{
|
||||
var segments = relativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var segment = segments.FirstOrDefault(NonGameSegments.Contains);
|
||||
if (segment is not null) return new("NonGameSupport", $"directory:{segment}");
|
||||
var fileName = segments.LastOrDefault() ?? string.Empty;
|
||||
if (SupportFileName().IsMatch(fileName)) return new("NonGameSupport", $"support-file:{fileName}");
|
||||
if (Path.GetExtension(fileName) is ".html" or ".htm")
|
||||
return new("NonGameSupport", $"web-page:{fileName}");
|
||||
var contentRoot = segments.Length > 1 && segments[0].Equals("PC", StringComparison.OrdinalIgnoreCase) ? segments[1] : null;
|
||||
return contentRoot is not null && NonGamePcRoot().IsMatch(contentRoot)
|
||||
? new("NonGameSupport", $"pc-root:{contentRoot}")
|
||||
: null;
|
||||
}
|
||||
|
||||
public static string CanonicalGameTitle(string rawTitle)
|
||||
{
|
||||
var extension = Path.GetExtension(rawTitle);
|
||||
var title = (GameExtensions.Contains(extension) ? rawTitle[..^extension.Length] : rawTitle).Replace('_', ' ');
|
||||
if (title.Contains('.') && !title.Contains(". ", StringComparison.Ordinal)) title = title.Replace('.', ' ');
|
||||
title = SceneSuffix().Replace(title, string.Empty);
|
||||
title = ReleaseTag().Replace(title, string.Empty);
|
||||
title = BracketMetadata().Replace(title, string.Empty);
|
||||
title = ParenthesizedMetadata().Replace(title, string.Empty);
|
||||
title = DiscSuffix().Replace(title, string.Empty);
|
||||
title = PlatformSuffix().Replace(title, string.Empty);
|
||||
title = ProductCodeSuffix().Replace(title, string.Empty);
|
||||
title = DanglingMetadata().Replace(title, string.Empty);
|
||||
title = ParenthesizedMetadata().Replace(title, string.Empty);
|
||||
title = DuplicatePossessive().Replace(title, "$1's");
|
||||
title = CatalogSequence().Replace(title, string.Empty);
|
||||
title = Whitespace().Replace(title, " ").Trim(' ', '-', '.');
|
||||
return string.IsNullOrWhiteSpace(title) ? rawTitle.Trim() : title;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(?i)\s*(?:[-–]\s*)?(?:duplex|codex|flt|reloaded|skidrow|gog|razor1911|venom|psypsp|internal-ps5b)\s*$")]
|
||||
private static partial Regex SceneSuffix();
|
||||
|
||||
[GeneratedRegex(@"(?i)\s*\[(?:fitgirl(?: monkey)? repack|dodi repack|gog|portable|repack|update[^\]]*|[a-z0-9]{6})\]\s*$")]
|
||||
private static partial Regex BracketMetadata();
|
||||
|
||||
[GeneratedRegex(@"(?i)\s+(?:(?:usa|eur|pal|ntsc)\s+)?(?:multi\d+\s+)?(?:clean\s+)?(?:nds|3ds|wii|gba|psp|ps2|ps3|ps4|ps5|nsw)-[a-z0-9]+$")]
|
||||
private static partial Regex ReleaseTag();
|
||||
|
||||
[GeneratedRegex(@"(?i)\s*\((?:usa|europe|japan|world|australia|korea|asia)(?:[^)]*)\)\s*(?:\([^)]*\)\s*)*$")]
|
||||
private static partial Regex ParenthesizedMetadata();
|
||||
|
||||
[GeneratedRegex(@"(?i)\s*(?:\(|\[)?(?:disc|disk|cd)\s*\d+(?:\)|\])?\s*$")]
|
||||
private static partial Regex DiscSuffix();
|
||||
|
||||
[GeneratedRegex(@"(?i)(?:\s+|-\s*)(?:ps[1-5]|playstation\s*[1-5]?|psp|ps\s*vita|xbox(?:\s*(?:one|360|series\s*[xs]))?|switch|wiiu?|gamecube|n64|snes|nes|gba|gbc|game\s*boy(?:\s*(?:advance|color))?|nds|3ds)\s*$")]
|
||||
private static partial Regex PlatformSuffix();
|
||||
|
||||
[GeneratedRegex(@"(?i)\s*(?:\[(?:u|e|j|ntsc(?:-[uj])?|pal|scus-\d+|sles-\d+|slus-\d+|ul[eus]-\d+)\]\s*)+$")]
|
||||
private static partial Regex ProductCodeSuffix();
|
||||
|
||||
[GeneratedRegex(@"(?i)\s*[\[(](?:usa|eur(?:ope)?|japan|world|australia|korea|asia|v(?:er(?:sion)?)?\s*\d+)[^\])]*$")]
|
||||
private static partial Regex DanglingMetadata();
|
||||
|
||||
[GeneratedRegex(@"(?i)\b([a-z]+)s's\b")]
|
||||
private static partial Regex DuplicatePossessive();
|
||||
|
||||
[GeneratedRegex(@"^\d{3,5}\s*-\s*")]
|
||||
private static partial Regex CatalogSequence();
|
||||
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex Whitespace();
|
||||
|
||||
[GeneratedRegex(@"(?i)^(?:steam|fmt-v\d.*|frosty\s*mod\s*tool.*|.*(?:dlc\s*unlocker|emulator|trainer|crack|keygen|mod\s*manager|save\s*editor).*)$")]
|
||||
private static partial Regex NonGamePcRoot();
|
||||
|
||||
[GeneratedRegex(@"(?i)^(?:readme|changelog|changes|license|licence|credits|manual|compatibility)(?:[._ -].*)?\.(?:txt|md|pdf|nfo|html?)$|^(?:prod|title|console)\.keys$")]
|
||||
private static partial Regex SupportFileName();
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public static partial class LibraryExperiencePolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// The regular-expression alternation a derived title strips from its end, longest first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One vocabulary, derived from what the identifier recognises, so a format added there cannot
|
||||
/// leave its extension sitting in a title. The extras are container formats Ludarium accepts by
|
||||
/// name without classifying them by extension.
|
||||
/// </remarks>
|
||||
public static string TitleExtensionPattern { get; } = string.Join('|', ArtifactAnalysis.KnownExtensions
|
||||
.Concat(["wad", "wua"])
|
||||
.Select(Regex.Escape)
|
||||
.OrderByDescending(extension => extension.Length)
|
||||
.ThenBy(extension => extension, StringComparer.Ordinal));
|
||||
|
||||
public static string CleanTitle(string value, string label = "Title") => Required(value, 200, label);
|
||||
|
||||
public static GameCollectionInput Normalize(GameCollectionInput input)
|
||||
{
|
||||
var name = Required(input.Name, 120, "Collection name");
|
||||
var description = Optional(input.Description, 1000);
|
||||
var rule = input.Kind == CollectionKind.Static ? null : Normalize(input.Rule ?? new CollectionRule());
|
||||
return input with { Name = name, Description = description, Rule = rule };
|
||||
}
|
||||
|
||||
public static CollectionRule Normalize(CollectionRule rule)
|
||||
{
|
||||
var tags = (rule.Tags ?? [])
|
||||
.Select(NormalizeTag)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Take(20)
|
||||
.ToArray();
|
||||
return rule with
|
||||
{
|
||||
Query = Optional(rule.Query, 200),
|
||||
Platform = Optional(rule.Platform, 80)?.ToLowerInvariant(),
|
||||
Tags = tags,
|
||||
MetadataField = Optional(rule.MetadataField, 80),
|
||||
MetadataValue = Optional(rule.MetadataValue, 200)
|
||||
};
|
||||
}
|
||||
|
||||
public static string NormalizeTag(string value)
|
||||
{
|
||||
var tag = Required(value, 60, "Tag");
|
||||
tag = Whitespace().Replace(tag, " ");
|
||||
if (tag.Any(char.IsControl)) throw new ArgumentException("A tag cannot contain control characters.");
|
||||
return tag.ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static GameUserStateInput Normalize(GameUserStateInput input)
|
||||
{
|
||||
if (input.Rating is < 1 or > 10) throw new ArgumentException("Rating must be between 1 and 10.");
|
||||
if (input.Difficulty is < 1 or > 10) throw new ArgumentException("Difficulty must be between 1 and 10.");
|
||||
if (input.CompletionPercent is < 0 or > 100) throw new ArgumentException("Completion must be between 0 and 100 percent.");
|
||||
if (input.PlayCount < 0) throw new ArgumentException("Play count cannot be negative.");
|
||||
return input with { Notes = Optional(input.Notes, 4000), PlayCount = Math.Min(input.PlayCount, 1_000_000) };
|
||||
}
|
||||
|
||||
public static GameRelationInput Normalize(GameRelationInput input)
|
||||
{
|
||||
if (input.RelatedGameId is not null && input.ArtifactId is not null)
|
||||
throw new ArgumentException("A relationship can target either a game or an artifact, not both.");
|
||||
return input with { Title = Required(input.Title, 200, "Relationship title") };
|
||||
}
|
||||
|
||||
public static GameMediaInput Normalize(GameMediaInput input)
|
||||
{
|
||||
var location = Optional(input.Location, 2000);
|
||||
if (location is not null && (!Uri.TryCreate(location, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps))
|
||||
throw new ArgumentException("External media locations must use HTTPS.");
|
||||
return input with
|
||||
{
|
||||
Title = Required(input.Title, 200, "Media title"),
|
||||
Location = location,
|
||||
Provider = Optional(input.Provider, 80),
|
||||
ExternalId = Optional(input.ExternalId, 200)
|
||||
};
|
||||
}
|
||||
|
||||
public static PlatformDefinitionInput Normalize(PlatformDefinitionInput input)
|
||||
{
|
||||
var id = Required(input.Id, 80, "Platform id").ToLowerInvariant();
|
||||
if (!PlatformId().IsMatch(id)) throw new ArgumentException("Platform id may contain lowercase letters, digits and hyphens only.");
|
||||
var aliases = input.Aliases.Select(value => Required(value, 80, "Platform alias").ToLowerInvariant())
|
||||
.Distinct(StringComparer.Ordinal).Take(50).ToArray();
|
||||
return input with
|
||||
{
|
||||
Id = id,
|
||||
Name = Required(input.Name, 120, "Platform name"),
|
||||
Category = Required(input.Category, 80, "Platform category"),
|
||||
Aliases = aliases
|
||||
};
|
||||
}
|
||||
|
||||
private static string Required(string? value, int maximum, string label) =>
|
||||
Optional(value, maximum) ?? throw new ArgumentException($"{label} is required.");
|
||||
|
||||
private static string? Optional(string? value, int maximum)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var result = value.Trim();
|
||||
if (result.Length > maximum) throw new ArgumentException($"Value cannot exceed {maximum} characters.");
|
||||
return result;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\s+", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex Whitespace();
|
||||
|
||||
[GeneratedRegex("^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex PlatformId();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Ludarium.Domain\Ludarium.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenMcdf" Version="3.2.0" />
|
||||
<PackageReference Include="SharpCompress" Version="0.50.4" />
|
||||
<PackageReference Include="System.IO.Hashing" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using OpenMcdf;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public sealed record MsiCabinetRelationship(int DiskId, int LastSequence, string Cabinet, bool Embedded,
|
||||
bool StreamPresent, string? VolumeLabel, string? Source);
|
||||
|
||||
public sealed record MsiFileRelationship(string Key, string FileName, long Size, int Sequence,
|
||||
string? Version, string? Language, int Attributes);
|
||||
|
||||
public sealed record MsiDatabaseInspection(IReadOnlyDictionary<string, string> Properties,
|
||||
IReadOnlyList<MsiCabinetRelationship> Cabinets, IReadOnlyList<MsiFileRelationship> Files,
|
||||
int CodePage, IReadOnlyList<string> Findings)
|
||||
{
|
||||
public bool IsComplete => Findings.Count == 0;
|
||||
}
|
||||
|
||||
public static class MsiDatabaseAnalysis
|
||||
{
|
||||
private const int MaxStreamBytes = 8 * 1024 * 1024;
|
||||
private const int MaxTotalBytes = 24 * 1024 * 1024;
|
||||
private const int MaxStrings = 100_000;
|
||||
private const int MaxRows = 100_000;
|
||||
private const string Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz._!";
|
||||
|
||||
public static MsiDatabaseInspection Inspect(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path))
|
||||
throw new ArgumentException("MSI path must be absolute.", nameof(path));
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using var root = RootStorage.OpenRead(path);
|
||||
var budget = new ReadBudget(MaxTotalBytes);
|
||||
var pool = ReadRequired(root, "_StringPool", budget, cancellationToken);
|
||||
var data = ReadRequired(root, "_StringData", budget, cancellationToken);
|
||||
var strings = ParseStrings(pool, data);
|
||||
var columns = ParseColumns(ReadRequired(root, "_Columns", budget, cancellationToken), strings);
|
||||
var findings = new List<string>();
|
||||
|
||||
var properties = ReadTable(root, "Property", columns, strings, budget, findings, cancellationToken)
|
||||
.Where(row => row.TryGetValue("Property", out _) && row.TryGetValue("Value", out _))
|
||||
.GroupBy(row => row["Property"], StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.Last()["Value"], StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var media = ReadTable(root, "Media", columns, strings, budget, findings, cancellationToken);
|
||||
var cabinets = new List<MsiCabinetRelationship>(media.Count);
|
||||
foreach (var row in media)
|
||||
{
|
||||
var cabinet = Value(row, "Cabinet");
|
||||
if (cabinet.Length == 0) continue;
|
||||
var embedded = cabinet[0] == '#';
|
||||
var cabinetName = embedded ? cabinet[1..] : cabinet;
|
||||
var present = embedded && StreamExists(root, cabinetName);
|
||||
if (embedded && !present) findings.Add($"Embedded cabinet stream '{cabinetName}' is missing.");
|
||||
cabinets.Add(new(ParseInt(row, "DiskId"), ParseInt(row, "LastSequence"), cabinetName,
|
||||
embedded, present, NullIfEmpty(Value(row, "VolumeLabel")), NullIfEmpty(Value(row, "Source"))));
|
||||
}
|
||||
|
||||
var fileRows = ReadTable(root, "File", columns, strings, budget, findings, cancellationToken);
|
||||
var files = fileRows.Select(row => new MsiFileRelationship(Value(row, "File"), Value(row, "FileName"),
|
||||
ParseLong(row, "FileSize"), ParseInt(row, "Sequence"), NullIfEmpty(Value(row, "Version")),
|
||||
NullIfEmpty(Value(row, "Language")), ParseInt(row, "Attributes"))).ToArray();
|
||||
|
||||
foreach (var file in files)
|
||||
if (file.Sequence <= 0) findings.Add($"File '{file.Key}' has an invalid media sequence.");
|
||||
if (cabinets.Count > 0 && files.Any(file => file.Sequence > cabinets.Max(cabinet => cabinet.LastSequence)))
|
||||
findings.Add("One or more files are not covered by a Media table sequence range.");
|
||||
|
||||
return new(properties, cabinets, files, strings.CodePage, findings.Distinct(StringComparer.Ordinal).ToArray());
|
||||
}
|
||||
|
||||
internal static string EncodeStreamName(string name)
|
||||
{
|
||||
var result = new StringBuilder((name.Length + 1) / 2);
|
||||
for (var index = 0; index < name.Length; index += 2)
|
||||
{
|
||||
var first = Alphabet.IndexOf(name[index]);
|
||||
var second = index + 1 < name.Length ? Alphabet.IndexOf(name[index + 1]) : -1;
|
||||
if (first >= 0 && second >= 0) result.Append((char)(0x3800 + first + (second << 6)));
|
||||
else if (first >= 0 && second < 0) result.Append((char)(0x4800 + first));
|
||||
else
|
||||
{
|
||||
result.Append(name[index]);
|
||||
if (index + 1 < name.Length) { result.Append(name[index + 1]); index++; }
|
||||
}
|
||||
}
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private static MsiStrings ParseStrings(byte[] poolBytes, byte[] dataBytes)
|
||||
{
|
||||
if (poolBytes.Length < 4) throw new InvalidDataException("MSI string pool header is truncated.");
|
||||
var codePage = BinaryPrimitives.ReadUInt16LittleEndian(poolBytes);
|
||||
var offset = 4;
|
||||
var dataOffset = 0;
|
||||
var values = new List<string>();
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
Encoding encoding;
|
||||
try { encoding = codePage == 0 ? Encoding.Latin1 : Encoding.GetEncoding(codePage); }
|
||||
catch (ArgumentException) { encoding = Encoding.Latin1; }
|
||||
while (offset < poolBytes.Length)
|
||||
{
|
||||
if (offset + 4 > poolBytes.Length) throw new InvalidDataException("MSI string pool entry is truncated.");
|
||||
var length = BinaryPrimitives.ReadUInt16LittleEndian(poolBytes.AsSpan(offset, 2));
|
||||
var references = BinaryPrimitives.ReadUInt16LittleEndian(poolBytes.AsSpan(offset + 2, 2));
|
||||
offset += 4;
|
||||
long actualLength = length;
|
||||
if (length == 0 && references != 0)
|
||||
{
|
||||
if (offset + 4 > poolBytes.Length) throw new InvalidDataException("MSI long string length is truncated.");
|
||||
actualLength = BinaryPrimitives.ReadUInt32LittleEndian(poolBytes.AsSpan(offset, 4));
|
||||
offset += 4;
|
||||
}
|
||||
if (actualLength < 0 || actualLength > MaxStreamBytes || dataOffset + actualLength > dataBytes.Length)
|
||||
throw new InvalidDataException("MSI string data exceeds its declared bounds.");
|
||||
values.Add(encoding.GetString(dataBytes, dataOffset, (int)actualLength));
|
||||
dataOffset += (int)actualLength;
|
||||
if (values.Count > MaxStrings) throw new InvalidDataException("MSI string pool exceeds the configured entry limit.");
|
||||
}
|
||||
return new(codePage, values);
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<MsiColumn>> ParseColumns(byte[] bytes, MsiStrings strings)
|
||||
{
|
||||
const int rowSize = 8;
|
||||
if (bytes.Length % rowSize != 0) throw new InvalidDataException("MSI _Columns table is truncated.");
|
||||
var rows = bytes.Length / rowSize;
|
||||
if (rows > MaxRows) throw new InvalidDataException("MSI _Columns table exceeds the row limit.");
|
||||
var result = new Dictionary<string, List<MsiColumn>>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var row = 0; row < rows; row++)
|
||||
{
|
||||
var tableId = ReadColumnU16(bytes, rows, 0, row);
|
||||
var number = ReadColumnU16(bytes, rows, 1, row);
|
||||
var nameId = ReadColumnU16(bytes, rows, 2, row);
|
||||
var attributes = ReadColumnU16(bytes, rows, 3, row);
|
||||
var table = strings.Get(tableId);
|
||||
if (!result.TryGetValue(table, out var list)) result[table] = list = [];
|
||||
list.Add(new(strings.Get(nameId), number, attributes));
|
||||
}
|
||||
foreach (var list in result.Values) list.Sort((left, right) => left.Number.CompareTo(right.Number));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, string>> ReadTable(RootStorage root, string tableName,
|
||||
IReadOnlyDictionary<string, List<MsiColumn>> allColumns, MsiStrings strings, ReadBudget budget,
|
||||
List<string> findings, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!allColumns.TryGetValue(tableName, out var columns)) return [];
|
||||
byte[] bytes;
|
||||
try { bytes = ReadRequired(root, tableName, budget, cancellationToken); }
|
||||
catch (Exception exception) when (exception is KeyNotFoundException or FileNotFoundException)
|
||||
{ findings.Add($"Declared MSI table '{tableName}' has no stream."); return []; }
|
||||
var rowWidth = columns.Sum(column => column.Width);
|
||||
if (rowWidth <= 0 || bytes.Length % rowWidth != 0)
|
||||
throw new InvalidDataException($"MSI table '{tableName}' has an invalid row layout.");
|
||||
var rowCount = bytes.Length / rowWidth;
|
||||
if (rowCount > MaxRows) throw new InvalidDataException($"MSI table '{tableName}' exceeds the row limit.");
|
||||
var columnOffset = 0;
|
||||
var output = Enumerable.Range(0, rowCount).Select(_ => new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)).ToList();
|
||||
foreach (var column in columns)
|
||||
{
|
||||
for (var row = 0; row < rowCount; row++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var offset = columnOffset + row * column.Width;
|
||||
long raw = column.Width == 4
|
||||
? BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(offset, 4))
|
||||
: BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(offset, 2));
|
||||
output[row][column.Name] = column.IsInteger
|
||||
? raw == 0 ? string.Empty : (raw - (column.Width == 4 ? 0x80000000L : 0x8000L)).ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
: raw == 0 ? string.Empty : strings.Get(checked((int)raw));
|
||||
}
|
||||
columnOffset += rowCount * column.Width;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private static byte[] ReadRequired(RootStorage root, string logicalName, ReadBudget budget, CancellationToken token)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
using var stream = root.OpenStream(EncodeStreamName(logicalName));
|
||||
if (stream.Length > MaxStreamBytes) throw new InvalidDataException($"MSI stream '{logicalName}' exceeds the configured limit.");
|
||||
budget.Add(stream.Length);
|
||||
var bytes = new byte[checked((int)stream.Length)];
|
||||
stream.ReadExactly(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static bool StreamExists(RootStorage root, string logicalName)
|
||||
{
|
||||
try { using var _ = root.OpenStream(EncodeStreamName(logicalName)); return true; }
|
||||
catch (Exception exception) when (exception is KeyNotFoundException or FileNotFoundException) { return false; }
|
||||
}
|
||||
|
||||
private static ushort ReadColumnU16(byte[] bytes, int rows, int column, int row) =>
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan((column * rows + row) * 2, 2));
|
||||
private static string Value(IReadOnlyDictionary<string, string> row, string key) => row.TryGetValue(key, out var value) ? value : string.Empty;
|
||||
private static int ParseInt(IReadOnlyDictionary<string, string> row, string key) => int.TryParse(Value(row, key), out var value) ? value : 0;
|
||||
private static long ParseLong(IReadOnlyDictionary<string, string> row, string key) => long.TryParse(Value(row, key), out var value) ? value : 0;
|
||||
private static string? NullIfEmpty(string value) => value.Length == 0 ? null : value;
|
||||
|
||||
private sealed record MsiColumn(string Name, int Number, int Attributes)
|
||||
{
|
||||
public bool IsInteger => (Attributes & 0x0f00) < 0x0800;
|
||||
public int Width => IsInteger && (Attributes & 0x0fff) == 0x0104 ? 4 : 2;
|
||||
}
|
||||
|
||||
private sealed record MsiStrings(int CodePage, IReadOnlyList<string> Values)
|
||||
{
|
||||
public string Get(int reference) => reference > 0 && reference <= Values.Count
|
||||
? Values[reference - 1]
|
||||
: throw new InvalidDataException($"MSI string reference {reference} is out of bounds.");
|
||||
}
|
||||
|
||||
private sealed class ReadBudget(long limit)
|
||||
{
|
||||
private long _used;
|
||||
public void Add(long bytes)
|
||||
{
|
||||
_used = checked(_used + bytes);
|
||||
if (_used > limit) throw new InvalidDataException("MSI metadata exceeds the aggregate read limit.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace Ludarium.Application;
|
||||
|
||||
/// <summary>Why a native remote player can or cannot start an exact linked title.</summary>
|
||||
/// <remarks>
|
||||
/// One shared vocabulary across every native player. A player that cannot reach a state simply never
|
||||
/// returns it, which keeps the UI, the playability workspace and the API contract identical no matter
|
||||
/// which sidecar answers.
|
||||
/// </remarks>
|
||||
public enum NativeRemotePlayState
|
||||
{
|
||||
Available,
|
||||
UnsupportedPlatform,
|
||||
RuntimeUnavailable,
|
||||
MissingKeys,
|
||||
MissingGame,
|
||||
MissingBaseGame,
|
||||
SourceNotReadOnly,
|
||||
AmbiguousMapping
|
||||
}
|
||||
|
||||
/// <summary>The evaluated readiness of one game in one native remote player.</summary>
|
||||
public sealed record NativeRemotePlayCapability(
|
||||
Guid GameId,
|
||||
bool Available,
|
||||
NativeRemotePlayState State,
|
||||
string Message,
|
||||
string Emulator,
|
||||
bool DesktopRecommended,
|
||||
DateTimeOffset CheckedAt,
|
||||
bool AutomaticRestore = false,
|
||||
string? Platform = null,
|
||||
string? Player = null,
|
||||
bool SupportsSavestates = false);
|
||||
|
||||
public static class NativeRemotePlayPolicy
|
||||
{
|
||||
/// <summary>A path Ludarium is willing to hand to a sidecar: relative, bounded and non-escaping.</summary>
|
||||
public static bool IsSafeRelativePath(string? path) => !string.IsNullOrWhiteSpace(path) &&
|
||||
!Path.IsPathRooted(path) && !path.Contains(':') && !path.Contains('\0') &&
|
||||
path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries).All(part => part is not "." and not "..");
|
||||
|
||||
/// <summary>
|
||||
/// Rebase a scan-derived source path onto the sidecar's own read-only mount.
|
||||
/// The prefix is operator-configured and must match exactly, so a game outside the configured
|
||||
/// catalog can never be addressed through the runtime.
|
||||
/// </summary>
|
||||
public static string ToRuntimePath(string relativePath, string catalogPrefix, IReadOnlySet<string> extensions,
|
||||
string? platformDirectory = null)
|
||||
{
|
||||
var normalized = relativePath.Replace('\\', '/').TrimStart('/');
|
||||
var prefix = catalogPrefix.Replace('\\', '/').Trim('/');
|
||||
if (string.IsNullOrWhiteSpace(prefix) || !normalized.StartsWith(prefix + "/", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("The selected game is outside the configured catalog prefix.");
|
||||
var suffix = normalized[(prefix.Length + 1)..];
|
||||
if (!IsSafeRelativePath(suffix) || !extensions.Contains(Path.GetExtension(suffix)))
|
||||
throw new InvalidOperationException("The selected game path is unsafe or unsupported.");
|
||||
return platformDirectory is null ? suffix : $"{platformDirectory.ToLowerInvariant()}/{suffix}";
|
||||
}
|
||||
|
||||
public static NativeRemotePlayCapability Unavailable(NativeRemotePlayer player, Guid gameId,
|
||||
NativeRemotePlayState state, string message, DateTimeOffset checkedAt, string? platform = null) =>
|
||||
new(gameId, false, state, message, player.Emulator, true, checkedAt, false, platform, player.Key,
|
||||
player.SupportsSavestates);
|
||||
|
||||
public static NativeRemotePlayCapability Available(NativeRemotePlayer player, Guid gameId, string message,
|
||||
DateTimeOffset checkedAt, string? platform, bool automaticRestore) =>
|
||||
new(gameId, true, NativeRemotePlayState.Available, message, player.Emulator, true, checkedAt,
|
||||
automaticRestore, platform, player.Key, player.SupportsSavestates);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
namespace Ludarium.Application;
|
||||
|
||||
/// <summary>
|
||||
/// The static identity of one isolated native remote player.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every native player is the same shape: an isolated sidecar that runs exactly one linked title
|
||||
/// from a read-only mount, reached through an authenticated certificate-pinned proxy and an
|
||||
/// exact-title control endpoint. Only the emulator, the platforms it owns, the container formats it
|
||||
/// accepts and the controls it exposes differ. Keeping that here — instead of spread across route
|
||||
/// literals, capability endpoints and UI conditionals — is what lets a new player (Azahar, xemu,
|
||||
/// Cemu, Vita3K) be added by describing it rather than by copying a vertical slice.
|
||||
/// </remarks>
|
||||
public sealed record NativeRemotePlayer(
|
||||
string Key,
|
||||
string Emulator,
|
||||
string ProxyHost,
|
||||
string ControlHost,
|
||||
int DefaultPublicPort,
|
||||
IReadOnlyList<string> Platforms,
|
||||
IReadOnlySet<string> Extensions,
|
||||
IReadOnlySet<string> Actions,
|
||||
string SaveMode,
|
||||
int MaximumSessions,
|
||||
bool RequiresFixtureDigest)
|
||||
{
|
||||
/// <summary>Route prefix of the embedded proxy, also the scope of its capability cookie.</summary>
|
||||
public string ProxyPath => $"/{Key}-player";
|
||||
|
||||
public string CookieName => $"ludarium-{Key}-session";
|
||||
|
||||
/// <summary>Prefix of every environment variable that configures this player.</summary>
|
||||
public string EnvironmentPrefix => $"LUDARIUM_{Key.ToUpperInvariant()}";
|
||||
|
||||
/// <summary>Route segment of this player's session endpoints.</summary>
|
||||
public string SessionRoute => $"{Key}-player-sessions";
|
||||
|
||||
public bool SupportsSavestates => Actions.Contains("save-state");
|
||||
|
||||
public bool Owns(string? platform) => platform is not null &&
|
||||
Platforms.Contains(platform, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool AcceptsExtension(string? relativePath) => relativePath is not null &&
|
||||
Extensions.Contains(Path.GetExtension(relativePath));
|
||||
}
|
||||
|
||||
public static class NativeRemotePlayerRegistry
|
||||
{
|
||||
/// <summary>Nintendo Switch through the isolated Eden runtime.</summary>
|
||||
public static readonly NativeRemotePlayer Switch = new(
|
||||
Key: "switch",
|
||||
Emulator: "Eden",
|
||||
ProxyHost: "eden",
|
||||
ControlHost: "eden",
|
||||
DefaultPublicPort: 1231,
|
||||
Platforms: ["switch"],
|
||||
Extensions: Set(".xci", ".nsp"),
|
||||
Actions: Set("pause-resume", "fullscreen", "stop"),
|
||||
SaveMode: "native-persistent",
|
||||
MaximumSessions: 2,
|
||||
RequiresFixtureDigest: false);
|
||||
|
||||
/// <summary>GameCube and Wii through the isolated Dolphin runtime.</summary>
|
||||
public static readonly NativeRemotePlayer Dolphin = new(
|
||||
Key: "dolphin",
|
||||
Emulator: "Dolphin",
|
||||
ProxyHost: "dolphin",
|
||||
ControlHost: "dolphin",
|
||||
DefaultPublicPort: 1232,
|
||||
Platforms: ["gamecube", "wii"],
|
||||
Extensions: Set(".iso", ".gcm", ".rvz", ".gcz", ".wbfs", ".wia"),
|
||||
Actions: Set("pause-resume", "fullscreen", "save-state", "load-state", "stop"),
|
||||
SaveMode: "native-and-savestate-persistent",
|
||||
MaximumSessions: 1,
|
||||
RequiresFixtureDigest: true);
|
||||
|
||||
public static IReadOnlyList<NativeRemotePlayer> All { get; } = [Switch, Dolphin];
|
||||
|
||||
/// <summary>Every platform served by a native remote player rather than by browser play.</summary>
|
||||
public static IReadOnlySet<string> Platforms { get; } =
|
||||
All.SelectMany(player => player.Platforms).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static NativeRemotePlayer? ForPlatform(string? platform) =>
|
||||
platform is null ? null : All.FirstOrDefault(player => player.Owns(platform));
|
||||
|
||||
public static NativeRemotePlayer? ForKey(string? key) => key is null ? null :
|
||||
All.FirstOrDefault(player => player.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the single native platform a game belongs to. A game whose releases name two native
|
||||
/// platforms is deliberately unresolvable: Ludarium must never guess which disc to launch.
|
||||
/// </summary>
|
||||
public static string? ResolvePlatform(IEnumerable<string?> releasePlatforms)
|
||||
{
|
||||
var matches = releasePlatforms
|
||||
.Select(platform => platform?.Trim().ToLowerInvariant())
|
||||
.Where(platform => platform is not null && Platforms.Contains(platform))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(2)
|
||||
.ToArray();
|
||||
return matches.Length == 1 ? matches[0] : null;
|
||||
}
|
||||
|
||||
private static HashSet<string> Set(params string[] values) => new(values, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime release identity derived from the repository VERSION file through MSBuild.
|
||||
/// </summary>
|
||||
public static class ReleaseIdentity
|
||||
{
|
||||
private static readonly string ResolvedVersion = ResolveVersion();
|
||||
|
||||
public static string Version => ResolvedVersion;
|
||||
public static string UserAgent => $"Ludarium/{ResolvedVersion}";
|
||||
|
||||
private static string ResolveVersion()
|
||||
{
|
||||
var informational = typeof(ReleaseIdentity).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||
if (string.IsNullOrWhiteSpace(informational))
|
||||
throw new InvalidOperationException("The Ludarium informational version is missing.");
|
||||
return informational.Split('+', 2, StringSplitOptions.TrimEntries)[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public enum ReviewGroupOrder { Recommended, Impact, Newest }
|
||||
|
||||
public sealed record ReviewGroupQuery
|
||||
{
|
||||
public ReviewState State { get; init; } = ReviewState.Open;
|
||||
public string? Category { get; init; }
|
||||
public string? Search { get; init; }
|
||||
public string? Library { get; init; }
|
||||
public string? Platform { get; init; }
|
||||
public Severity? Severity { get; init; }
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
public ReviewGroupOrder Order { get; init; } = ReviewGroupOrder.Recommended;
|
||||
public int Page { get; init; } = 1;
|
||||
public int PageSize { get; init; } = 50;
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
using System.Text;
|
||||
using System.Security.Cryptography;
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public sealed class ScanCoordinator(ILudariumStore store, IReadOnlyLibraryFileSystem files) : IScanCoordinator
|
||||
{
|
||||
public Task<ScanRun> RequestAsync(Guid libraryId, ScanMode mode, string? idempotencyKey, CancellationToken cancellationToken) =>
|
||||
store.CreateScanAsync(libraryId, mode, idempotencyKey, cancellationToken);
|
||||
|
||||
public async Task CancelAsync(Guid scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
var scan = await store.GetScanAsync(scanId, cancellationToken) ?? throw new KeyNotFoundException("Scan not found.");
|
||||
if (scan.State is ScanState.Completed or ScanState.Cancelled or ScanState.Failed) return;
|
||||
await store.UpdateScanAsync(scan with { CancellationRequested = true, State = ScanState.Cancelling, Stage = "Cancelling" }, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(Guid scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
var scan = await store.GetScanAsync(scanId, cancellationToken) ?? throw new KeyNotFoundException("Scan not found.");
|
||||
if (scan.State is ScanState.Completed or ScanState.Cancelled) return;
|
||||
var root = await store.GetLibraryAsync(scan.LibraryId, cancellationToken) ?? throw new InvalidOperationException("Library no longer exists.");
|
||||
try
|
||||
{
|
||||
if (!root.Enabled)
|
||||
throw new InvalidOperationException("Scan stopped: this library is dormant. Enable it before scanning.");
|
||||
scan = scan with { State = ScanState.Verifying, Stage = "Verifying root", StartedAt = scan.StartedAt ?? DateTimeOffset.UtcNow };
|
||||
await store.UpdateScanAsync(scan, cancellationToken);
|
||||
var verification = files.Verify(root.Path);
|
||||
var verifiedRoot = root with { IsAvailable = verification.Available, IsReadOnly = verification.IsReadOnly, HasEntries = verification.HasEntries, LastVerifiedAt = DateTimeOffset.UtcNow };
|
||||
await store.UpsertLibraryAsync(verifiedRoot, cancellationToken);
|
||||
if (HealthRules.EvaluateRoot(verifiedRoot) is { } rootHealth)
|
||||
await store.AddFindingAsync(new(DeterministicGuid(root.Id, rootHealth.Category, "root-health"), rootHealth.Category, rootHealth.Severity, rootHealth.Message, null, null, DateTimeOffset.UtcNow), cancellationToken);
|
||||
if (!verification.Available) throw new IOException(verification.Message);
|
||||
if (verification.HasEntries == false)
|
||||
{
|
||||
var stored = (await store.GetStorageSummaryAsync(cancellationToken)).Libraries
|
||||
.FirstOrDefault(item => item.LibraryId == root.Id);
|
||||
var presentArtifacts = stored is null ? 0 : stored.Artifacts - stored.Missing;
|
||||
if (presentArtifacts > 0)
|
||||
throw new IOException($"Scan stopped: the source is empty while {presentArtifacts} catalog artifacts remain present. Restore or populate the mount, verify it, and retry.");
|
||||
}
|
||||
|
||||
scan = scan with { State = ScanState.Discovering, Stage = "Discovering files" };
|
||||
await store.UpdateScanAsync(scan, cancellationToken);
|
||||
var observed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
await foreach (var file in files.EnumerateAsync(root, cancellationToken))
|
||||
{
|
||||
var latest = await store.GetScanAsync(scanId, cancellationToken);
|
||||
while (latest?.State == ScanState.Paused)
|
||||
{
|
||||
await Task.Delay(500, cancellationToken);
|
||||
latest = await store.GetScanAsync(scanId, cancellationToken);
|
||||
}
|
||||
if (latest?.CancellationRequested == true)
|
||||
{
|
||||
await store.UpdateScanAsync(latest with { State = ScanState.Cancelled, Stage = "Cancelled", FinishedAt = DateTimeOffset.UtcNow }, cancellationToken);
|
||||
return;
|
||||
}
|
||||
observed.Add(file.RelativePath);
|
||||
var existing = await store.FindByPathAsync(root.Id, file.RelativePath, cancellationToken);
|
||||
if (LibraryContentPolicy.IdentifyNonGamePath(file.RelativePath) is { } nonGame)
|
||||
{
|
||||
if (existing?.State != ArtifactState.Ignored || existing.MediaType != MediaType.SupportFile)
|
||||
{
|
||||
var evidence = new Evidence("content.role", nonGame.Evidence, "library-content-policy", "1",
|
||||
Confidence.Deterministic, DateTimeOffset.UtcNow);
|
||||
if (existing is not null)
|
||||
await store.MarkArtifactIgnoredAsync(existing.Id, existing.Version + 1, evidence, cancellationToken);
|
||||
else
|
||||
{
|
||||
var ignored = new Artifact(Guid.NewGuid(), root.Id, file.RelativePath, file.Size,
|
||||
file.ModifiedAt, ArtifactState.Ignored, MediaType.SupportFile, Confidence.Deterministic,
|
||||
null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null, 1);
|
||||
await store.UpsertArtifactAsync(ignored, null, [evidence], cancellationToken);
|
||||
scan = scan with { NewItems = scan.NewItems + 1 };
|
||||
}
|
||||
}
|
||||
scan = scan with { ItemsCompleted = scan.ItemsCompleted + 1, BytesCompleted = scan.BytesCompleted + file.Size };
|
||||
if (scan.ItemsCompleted % 250 == 0) await store.UpdateScanAsync(scan, cancellationToken);
|
||||
continue;
|
||||
}
|
||||
var unchanged = existing is not null && existing.Size == file.Size && existing.ModifiedAt == file.ModifiedAt && existing.ContentBlobId is not null;
|
||||
if (!unchanged)
|
||||
{
|
||||
await using var stream = await files.OpenReadAsync(root, file.RelativePath, cancellationToken);
|
||||
const int maxHeaderBytes = 66 * 1024;
|
||||
var header = new byte[Math.Min(maxHeaderBytes, (int)Math.Min(file.Size, maxHeaderBytes))];
|
||||
var read = await stream.ReadAsync(header, cancellationToken);
|
||||
stream.Position = 0;
|
||||
var platformHint = ArtifactAnalysis.InferPlatformHint(root.Name, root.Path, file.RelativePath);
|
||||
var classification = ArtifactAnalysis.Classify(file.RelativePath, header.AsSpan(0, read), platformHint);
|
||||
var inspectionEvidence = classification.Evidence.ToList();
|
||||
if (classification.MediaType == MediaType.DiscImage &&
|
||||
Path.GetExtension(file.RelativePath).Equals(".iso", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var disc = await DiscImageAnalysis.InspectIsoAsync(stream, cancellationToken);
|
||||
inspectionEvidence.AddRange(disc.Evidence);
|
||||
if (disc.Platform is not null)
|
||||
{
|
||||
for (var index = 0; index < inspectionEvidence.Count; index++)
|
||||
if (inspectionEvidence[index].Kind == "directory.platform")
|
||||
inspectionEvidence[index] = inspectionEvidence[index] with { Confidence = Confidence.Medium };
|
||||
if (platformHint is not null && !platformHint.Equals(disc.Platform, StringComparison.OrdinalIgnoreCase))
|
||||
inspectionEvidence.Add(new("platform.conflict", $"directory:{platformHint};disc:{disc.Platform}",
|
||||
"builtin-iso9660", "1", Confidence.High, DateTimeOffset.UtcNow));
|
||||
classification = classification with
|
||||
{
|
||||
Platform = disc.Platform,
|
||||
Confidence = disc.Confidence,
|
||||
Supported = true,
|
||||
Capability = "classify:iso9660-platform"
|
||||
};
|
||||
}
|
||||
stream.Position = 0;
|
||||
}
|
||||
if (classification.MediaType == MediaType.DiscImage &&
|
||||
Path.GetExtension(file.RelativePath).Equals(".pbp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var disc = await DiscImageAnalysis.InspectPbpAsync(stream, cancellationToken);
|
||||
inspectionEvidence.AddRange(disc.Evidence);
|
||||
if (disc.Platform is not null)
|
||||
{
|
||||
for (var index = 0; index < inspectionEvidence.Count; index++)
|
||||
if (inspectionEvidence[index].Kind == "directory.platform")
|
||||
inspectionEvidence[index] = inspectionEvidence[index] with { Confidence = Confidence.Medium };
|
||||
if (platformHint is not null && !platformHint.Equals(disc.Platform, StringComparison.OrdinalIgnoreCase))
|
||||
inspectionEvidence.Add(new("platform.conflict", $"directory:{platformHint};disc:{disc.Platform}",
|
||||
"builtin-pbp", "1", Confidence.High, DateTimeOffset.UtcNow));
|
||||
classification = classification with
|
||||
{
|
||||
Platform = disc.Platform,
|
||||
Confidence = disc.Confidence,
|
||||
Supported = true,
|
||||
Capability = "classify:pbp-platform"
|
||||
};
|
||||
}
|
||||
stream.Position = 0;
|
||||
}
|
||||
if (classification.MediaType == MediaType.WindowsPackage && Path.GetExtension(file.RelativePath).Equals(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try { inspectionEvidence.AddRange(WindowsPackageAnalysis.InspectPe(stream).Evidence); }
|
||||
catch (BadImageFormatException) { inspectionEvidence.Add(new("pe.inspection", "invalid-or-truncated", "pe-reader", "1", Confidence.Deterministic, DateTimeOffset.UtcNow)); }
|
||||
stream.Position = 0;
|
||||
}
|
||||
if (classification.MediaType == MediaType.WindowsPackage && Path.GetExtension(file.RelativePath).Equals(".msi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var msiPath = Path.Combine(root.Path, file.RelativePath);
|
||||
try
|
||||
{
|
||||
var summary = WindowsPackageAnalysis.InspectMsiSummary(msiPath);
|
||||
inspectionEvidence.AddRange(summary.Select(item => new Evidence($"msi.summary.{item.Key}", item.Value,
|
||||
"ole-property-set", "1", Confidence.Deterministic, DateTimeOffset.UtcNow)));
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or InvalidDataException or ArgumentException or OpenMcdf.FileFormatException)
|
||||
{
|
||||
inspectionEvidence.Add(new("msi.inspection", "invalid-or-truncated", "ole-property-set", "1",
|
||||
Confidence.Deterministic, DateTimeOffset.UtcNow));
|
||||
}
|
||||
try
|
||||
{
|
||||
var database = MsiDatabaseAnalysis.Inspect(msiPath, cancellationToken);
|
||||
inspectionEvidence.AddRange(database.Properties.Select(item => new Evidence($"msi.property.{item.Key}", item.Value,
|
||||
"msi-database", "1", Confidence.Deterministic, DateTimeOffset.UtcNow)));
|
||||
inspectionEvidence.Add(new("msi.files", database.Files.Count.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
"msi-database", "1", Confidence.Deterministic, DateTimeOffset.UtcNow));
|
||||
foreach (var cabinet in database.Cabinets)
|
||||
inspectionEvidence.Add(new("msi.cabinet", $"{(cabinet.Embedded ? "embedded" : "external")}:{cabinet.Cabinet}:{cabinet.LastSequence}",
|
||||
"msi-database", "1", Confidence.Deterministic, DateTimeOffset.UtcNow));
|
||||
foreach (var finding in database.Findings)
|
||||
inspectionEvidence.Add(new("msi.finding", finding, "msi-database", "1", Confidence.Deterministic, DateTimeOffset.UtcNow));
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or InvalidDataException or ArgumentException or OpenMcdf.FileFormatException)
|
||||
{
|
||||
inspectionEvidence.Add(new("msi.database.inspection", "invalid-truncated-or-unsupported", "msi-database", "1",
|
||||
Confidence.Deterministic, DateTimeOffset.UtcNow));
|
||||
}
|
||||
}
|
||||
HashResult? hashes = null;
|
||||
// An integrity scan is an explicit operator request for cryptographic evidence.
|
||||
// OnDemand is deliberately cheap for normal deep scans, but must never turn an
|
||||
// integrity run into a no-op.
|
||||
if (scan.Mode == ScanMode.Integrity || (scan.Mode != ScanMode.Quick && root.HashPolicy != HashPolicy.OnDemand))
|
||||
hashes = await ArtifactAnalysis.HashAsync(stream, root.HashPolicy == HashPolicy.CatalogCompatible, cancellationToken);
|
||||
|
||||
var moved = hashes is null ? null : await store.FindByHashAsync(root.Id, file.Size, hashes.Sha256, cancellationToken);
|
||||
var id = existing?.Id ?? moved?.Id ?? Guid.NewGuid();
|
||||
if (existing is null)
|
||||
scan = moved is null
|
||||
? scan with { NewItems = scan.NewItems + 1 }
|
||||
: scan with { MovedItems = scan.MovedItems + 1 };
|
||||
ArchiveInspection? archiveInspection = null;
|
||||
var archiveExtension = Path.GetExtension(file.RelativePath);
|
||||
if (root.InspectArchives && scan.Mode != ScanMode.Quick && classification.MediaType == MediaType.Archive &&
|
||||
(archiveExtension.Equals(".zip", StringComparison.OrdinalIgnoreCase) || archiveExtension.Equals(".7z", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
try
|
||||
{
|
||||
stream.Position = 0;
|
||||
archiveInspection = archiveExtension.Equals(".7z", StringComparison.OrdinalIgnoreCase)
|
||||
? ArchiveAnalysis.InspectSevenZip(id, stream, cancellationToken: cancellationToken)
|
||||
: ArchiveAnalysis.InspectZip(id, stream, cancellationToken: cancellationToken);
|
||||
inspectionEvidence.Add(new("archive.members", archiveInspection.Members.Count.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
archiveExtension.Equals(".7z", StringComparison.OrdinalIgnoreCase) ? "7z-header" : "zip-central-directory", "1", Confidence.Deterministic, DateTimeOffset.UtcNow));
|
||||
}
|
||||
catch (Exception exception) when (exception is InvalidDataException or FormatException)
|
||||
{
|
||||
inspectionEvidence.Add(new("archive.inspection", "invalid-or-truncated", archiveExtension.Equals(".7z", StringComparison.OrdinalIgnoreCase) ? "7z-header" : "zip-central-directory", "1",
|
||||
Confidence.Deterministic, DateTimeOffset.UtcNow));
|
||||
}
|
||||
}
|
||||
var content = hashes is null ? null : new ContentBlob(Guid.NewGuid(), file.Size, hashes.Sha256,
|
||||
NullIfEmpty(hashes.Sha1), NullIfEmpty(hashes.Crc32), DateTimeOffset.UtcNow);
|
||||
var artifact = new Artifact(id, root.Id, file.RelativePath, file.Size, file.ModifiedAt, ArtifactState.Present,
|
||||
classification.MediaType, classification.Confidence, content?.Id ?? existing?.ContentBlobId,
|
||||
existing?.FirstSeenAt ?? moved?.FirstSeenAt ?? DateTimeOffset.UtcNow, DateTimeOffset.UtcNow,
|
||||
classification.Evidence.FirstOrDefault(x => x.Kind == "signature")?.Value, classification.Platform,
|
||||
(existing?.Version ?? moved?.Version ?? 0) + 1, hashes?.Sha256 ?? existing?.Sha256);
|
||||
if (await store.GetArtifactOverrideAsync(id, cancellationToken) is { } manual)
|
||||
{
|
||||
artifact = artifact with
|
||||
{
|
||||
State = manual.Ignore ? ArtifactState.Ignored : artifact.State,
|
||||
MediaType = manual.MediaType ?? artifact.MediaType,
|
||||
Platform = manual.Platform ?? artifact.Platform,
|
||||
Confidence = Confidence.Deterministic
|
||||
};
|
||||
inspectionEvidence.Add(new("classification.manual-override", manual.Ignore ? "ignored" : manual.Platform ?? manual.MediaType?.ToString() ?? "applied",
|
||||
"operator", "1", Confidence.Deterministic, manual.CreatedAt));
|
||||
}
|
||||
if (existing?.Sha256 is { } previousHash && hashes is not null && !previousHash.Equals(hashes.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scan = scan with { ChangedItems = scan.ChangedItems + 1 };
|
||||
await store.AddFindingAsync(new(Guid.NewGuid(), "ChangedVerifiedContent", Severity.Critical,
|
||||
"Verified content changed at an existing artifact path.", artifact.Id, null, DateTimeOffset.UtcNow), cancellationToken);
|
||||
}
|
||||
await store.UpsertArtifactAsync(artifact, content, inspectionEvidence, cancellationToken);
|
||||
if (archiveInspection is not null)
|
||||
{
|
||||
await store.ReplaceArchiveMembersAsync(artifact.Id, archiveInspection.Members, cancellationToken);
|
||||
foreach (var finding in archiveInspection.Findings)
|
||||
await store.AddFindingAsync(new(DeterministicGuid(artifact.Id, finding, "archive-health"), "ArchiveSafety", Severity.Warning,
|
||||
finding, artifact.Id, null, DateTimeOffset.UtcNow), cancellationToken);
|
||||
}
|
||||
if (HealthRules.EvaluateArtifact(artifact) is { } artifactHealth)
|
||||
await store.AddFindingAsync(new(DeterministicGuid(artifact.Id, artifactHealth.Category, "artifact-health"), artifactHealth.Category, artifactHealth.Severity, artifactHealth.Message, artifact.Id, null, DateTimeOffset.UtcNow), cancellationToken);
|
||||
if (classification.MediaType == MediaType.Unknown)
|
||||
await store.AddReviewAsync(new(DeterministicGuid(artifact.Id, "unknown-review"), "Unknown content", Severity.Notice, ReviewState.Open,
|
||||
System.Text.Json.JsonSerializer.Serialize(new { artifact.Id, artifact.RelativePath }), DateTimeOffset.UtcNow), cancellationToken);
|
||||
}
|
||||
scan = scan with
|
||||
{
|
||||
ItemsCompleted = scan.ItemsCompleted + 1,
|
||||
BytesCompleted = scan.BytesCompleted + file.Size,
|
||||
Checkpoint = file.RelativePath,
|
||||
Stage = "Classifying and hashing"
|
||||
};
|
||||
await store.UpdateScanAsync(scan, cancellationToken);
|
||||
}
|
||||
await DetectDescriptorBundlesAsync(root, observed, cancellationToken);
|
||||
await DetectWindowsBundlesAsync(root, observed, cancellationToken);
|
||||
// Missing reconciliation is only reached after positive root verification and complete traversal.
|
||||
var deleted = await store.MarkMissingExceptAsync(root.Id, observed, DateTimeOffset.UtcNow, cancellationToken);
|
||||
scan = scan with { DeletedItems = deleted };
|
||||
await store.SynchronizeDiscoveredGamesAsync(root.Id, cancellationToken);
|
||||
await store.ReconcileWishlistAsync("scan-reconciliation", cancellationToken);
|
||||
await store.UpdateScanAsync(scan with { State = ScanState.Completed, Stage = "Complete", FinishedAt = DateTimeOffset.UtcNow }, cancellationToken);
|
||||
await store.EnqueueJobAsync("ArtworkEnrichment", "{}", $"artwork:scan:{scan.Id:N}", cancellationToken);
|
||||
await store.EnqueueJobAsync("MetadataEnrichment", "{}", $"metadata:scan:{scan.Id:N}", cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await store.UpdateScanAsync(scan with { State = ScanState.Cancelled, Stage = "Cancelled", FinishedAt = DateTimeOffset.UtcNow }, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await store.UpdateScanAsync(scan with { State = ScanState.Failed, Stage = "Failed", Error = ex.Message, FinishedAt = DateTimeOffset.UtcNow }, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DetectDescriptorBundleAsync(LibraryRoot root, Artifact artifact, CancellationToken cancellationToken)
|
||||
{
|
||||
var ext = Path.GetExtension(artifact.RelativePath);
|
||||
if (!ext.Equals(".cue", StringComparison.OrdinalIgnoreCase) && !ext.Equals(".m3u", StringComparison.OrdinalIgnoreCase)) return;
|
||||
await using var stream = await files.OpenReadAsync(root, artifact.RelativePath, cancellationToken);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, true, 4096, leaveOpen: false);
|
||||
var text = await reader.ReadToEndAsync(cancellationToken);
|
||||
var refs = ext.Equals(".cue", StringComparison.OrdinalIgnoreCase) ? ArtifactAnalysis.ParseCueReferences(text) : ArtifactAnalysis.ParseM3uReferences(text);
|
||||
var members = new List<BundleMember> { new(artifact.Id, "descriptor", 0, true, "explicit descriptor") };
|
||||
var findings = new List<string>();
|
||||
var directory = Path.GetDirectoryName(artifact.RelativePath) ?? string.Empty;
|
||||
var sequence = 1;
|
||||
foreach (var reference in refs)
|
||||
{
|
||||
if (!ArtifactAnalysis.IsSafeRelativeReference(reference)) { findings.Add($"Reference escapes the library boundary: {reference}"); continue; }
|
||||
var relative = Path.GetRelativePath(root.Path, files.ResolveContainedPath(root, Path.Combine(directory, reference))).Replace('\\', '/');
|
||||
var target = await store.FindByPathAsync(root.Id, relative, cancellationToken);
|
||||
if (target is null && ext.Equals(".cue", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
target = await store.FindByPathAsync(root.Id, relative + ".ecm", cancellationToken);
|
||||
if (target is not null)
|
||||
{
|
||||
members.Add(new(target.Id, "encoded track", sequence++, true,
|
||||
$"exact ECM sidecar for {reference}"));
|
||||
findings.Add($"Referenced track is present as legacy ECM content and needs derived-cache conversion for playback: {reference}.ecm");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (target is null) findings.Add($"Referenced file was not found: {reference}");
|
||||
else members.Add(new(target.Id, ext.Equals(".m3u", StringComparison.OrdinalIgnoreCase) ? "disc" : "track", sequence++, true, reference));
|
||||
}
|
||||
var onlyConvertibleEcm = findings.Count > 0 && findings.All(finding =>
|
||||
finding.StartsWith("Referenced track is present as legacy ECM content", StringComparison.Ordinal));
|
||||
var state = findings.Count == 0 ? BundleState.Complete
|
||||
: onlyConvertibleEcm ? BundleState.CompleteWithWarnings : BundleState.Incomplete;
|
||||
var bundleId = DeterministicGuid(root.Id, artifact.RelativePath, "descriptor-bundle");
|
||||
var bundle = new Bundle(bundleId, ext.Equals(".cue", StringComparison.OrdinalIgnoreCase) ? BundleKind.CueBin : BundleKind.MultiDisc,
|
||||
Path.GetFileNameWithoutExtension(artifact.RelativePath), state, members, findings);
|
||||
await store.AddBundleAsync(bundle, cancellationToken);
|
||||
if (HealthRules.EvaluateBundle(bundle) is { } bundleHealth)
|
||||
await store.AddFindingAsync(new(DeterministicGuid(bundleId, bundleHealth.Category, "bundle-health"), bundleHealth.Category, bundleHealth.Severity, bundleHealth.Message, null, bundleId, DateTimeOffset.UtcNow), cancellationToken);
|
||||
if (state == BundleState.Incomplete)
|
||||
await store.AddReviewAsync(new(DeterministicGuid(bundleId, "incomplete-review"), "Incomplete bundle", Severity.Warning, ReviewState.Open,
|
||||
System.Text.Json.JsonSerializer.Serialize(new { artifact.Id, findings }), DateTimeOffset.UtcNow), cancellationToken);
|
||||
}
|
||||
|
||||
private async Task DetectDescriptorBundlesAsync(LibraryRoot root, IReadOnlySet<string> observed, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var path in observed.Where(x => Path.GetExtension(x).Equals(".cue", StringComparison.OrdinalIgnoreCase) || Path.GetExtension(x).Equals(".m3u", StringComparison.OrdinalIgnoreCase)))
|
||||
if (await store.FindByPathAsync(root.Id, path, cancellationToken) is { } artifact)
|
||||
await DetectDescriptorBundleAsync(root, artifact, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task DetectWindowsBundlesAsync(LibraryRoot root, IReadOnlySet<string> observed, CancellationToken cancellationToken)
|
||||
{
|
||||
var groups = observed.GroupBy(x => (Path.GetDirectoryName(x) ?? string.Empty).Replace('\\', '/'), StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var directory in groups)
|
||||
{
|
||||
var paths = directory.Order(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
var primaries = paths.Where(x => (Path.GetExtension(x).Equals(".exe", StringComparison.OrdinalIgnoreCase) || Path.GetExtension(x).Equals(".msi", StringComparison.OrdinalIgnoreCase)) &&
|
||||
(Path.GetFileNameWithoutExtension(x).Contains("setup", StringComparison.OrdinalIgnoreCase) || Path.GetFileNameWithoutExtension(x).Contains("install", StringComparison.OrdinalIgnoreCase))).ToArray();
|
||||
var payloads = paths.Where(x => new[] { ".bin", ".cab", ".dat" }.Contains(Path.GetExtension(x), StringComparer.OrdinalIgnoreCase)).ToArray();
|
||||
if (primaries.Length == 0 || payloads.Length == 0) continue;
|
||||
var bundleId = DeterministicGuid(root.Id, directory.Key, "windows-installer");
|
||||
var members = new List<BundleMember>();
|
||||
foreach (var primary in primaries)
|
||||
if (await store.FindByPathAsync(root.Id, primary, cancellationToken) is { } artifact)
|
||||
members.Add(new(artifact.Id, "bootstrapper", members.Count, true, "installer filename and neighboring payloads"));
|
||||
foreach (var payload in payloads)
|
||||
if (await store.FindByPathAsync(root.Id, payload, cancellationToken) is { } artifact)
|
||||
members.Add(new(artifact.Id, "required payload", members.Count, true, "same-directory installer payload pattern"));
|
||||
var missing = WindowsPackageAnalysis.MissingNumberedPayloads(payloads);
|
||||
var findings = missing.Select(x => $"Numbered installer payload {x} is missing.").ToList();
|
||||
if (primaries.Length > 1) findings.Add("Multiple installer primaries were found; operator review is required.");
|
||||
var state = primaries.Length > 1 ? BundleState.Ambiguous : findings.Count > 0 ? BundleState.Incomplete : BundleState.Complete;
|
||||
var bundle = new Bundle(bundleId, BundleKind.WindowsInstaller, string.IsNullOrEmpty(directory.Key) ? Path.GetFileNameWithoutExtension(primaries[0]) : Path.GetFileName(directory.Key), state, members, findings);
|
||||
await store.AddBundleAsync(bundle, cancellationToken);
|
||||
if (HealthRules.EvaluateBundle(bundle) is { } bundleHealth)
|
||||
await store.AddFindingAsync(new(DeterministicGuid(bundleId, bundleHealth.Category, "bundle-health"), bundleHealth.Category, bundleHealth.Severity, bundleHealth.Message, null, bundleId, DateTimeOffset.UtcNow), cancellationToken);
|
||||
if (state is BundleState.Ambiguous or BundleState.Incomplete)
|
||||
await store.AddReviewAsync(new(DeterministicGuid(bundleId, "bundle-review"), state == BundleState.Ambiguous ? "Windows package role ambiguity" : "Incomplete bundle",
|
||||
Severity.Warning, ReviewState.Open, System.Text.Json.JsonSerializer.Serialize(new { bundleId, primaries, payloads, findings }), DateTimeOffset.UtcNow), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static Guid DeterministicGuid(Guid scope, string value, string kind) => new(SHA256.HashData(Encoding.UTF8.GetBytes($"{scope:N}|{kind}|{value}"))[..16]);
|
||||
private static Guid DeterministicGuid(Guid scope, string kind) => DeterministicGuid(scope, string.Empty, kind);
|
||||
|
||||
private static string? NullIfEmpty(string value) => string.IsNullOrEmpty(value) ? null : value;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Ludarium.Domain;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
public sealed class SnapshotComparer : ISnapshotComparer
|
||||
{
|
||||
public IReadOnlyList<SnapshotChange> Compare(IntegritySnapshot before, IntegritySnapshot after)
|
||||
{
|
||||
var changes = new List<SnapshotChange>();
|
||||
var beforeByArtifact = before.Items.ToDictionary(x => x.ArtifactId);
|
||||
var afterByArtifact = after.Items.ToDictionary(x => x.ArtifactId);
|
||||
foreach (var oldItem in before.Items)
|
||||
{
|
||||
if (afterByArtifact.TryGetValue(oldItem.ArtifactId, out var current))
|
||||
{
|
||||
if (!oldItem.Sha256.Equals(current.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
changes.Add(new(SnapshotChangeKind.ContentChanged, oldItem, current, "The artifact identity is retained but its verified SHA-256 changed."));
|
||||
else if (!oldItem.RelativePath.Equals(current.RelativePath, StringComparison.OrdinalIgnoreCase) || oldItem.LibraryId != current.LibraryId)
|
||||
changes.Add(new(SnapshotChangeKind.Moved, oldItem, current, "Verified content retained its artifact identity at a new location."));
|
||||
else changes.Add(new(SnapshotChangeKind.Unchanged, oldItem, current, "Path and verified content are unchanged."));
|
||||
}
|
||||
else changes.Add(new(SnapshotChangeKind.Removed, oldItem, null, "The artifact is absent from the later available-root snapshot."));
|
||||
}
|
||||
foreach (var current in after.Items.Where(x => !beforeByArtifact.ContainsKey(x.ArtifactId)))
|
||||
changes.Add(new(SnapshotChangeKind.Added, null, current, "A new artifact appears in the later snapshot."));
|
||||
return changes.OrderBy(x => x.Kind).ThenBy(x => x.After?.RelativePath ?? x.Before?.RelativePath, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Ludarium.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Selects the one unique read-only Switch base game an Eden session may launch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Switch is the only native player whose linked files are not interchangeable: updates and DLC
|
||||
/// share a directory with the base game and cannot start on their own, so the base title identity is
|
||||
/// read out of the dump name rather than inferred from the extension.
|
||||
/// </remarks>
|
||||
public static partial class SwitchLaunchPolicy
|
||||
{
|
||||
public static NativeRemotePlayer Player => NativeRemotePlayerRegistry.Switch;
|
||||
|
||||
/// <summary>Below this size a linked file is a companion artifact, not a base game.</summary>
|
||||
private const long MinimumImageBytes = 1024 * 1024;
|
||||
|
||||
[GeneratedRegex(@"\[(?<titleId>[0-9a-fA-F]{16})\]\[v(?<version>\d+)\]", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex TitleIdentity();
|
||||
|
||||
public static NativeLaunchCandidate? SelectBaseGame(IReadOnlyList<NativeLaunchCandidate> candidates)
|
||||
{
|
||||
var eligible = EligibleBaseGames(candidates);
|
||||
|
||||
var xci = eligible.Where(item => Path.GetExtension(item.Candidate.RelativePath).Equals(".xci", StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
if (xci.Length == 1) return xci[0].Candidate;
|
||||
if (xci.Length > 1) return null;
|
||||
|
||||
var nsp = eligible.Where(item => Path.GetExtension(item.Candidate.RelativePath).Equals(".nsp", StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
return nsp.Length == 1 ? nsp[0].Candidate : null;
|
||||
}
|
||||
|
||||
public static NativeRemotePlayCapability EvaluateCapability(Guid gameId, bool isSwitchGame,
|
||||
bool runtimeReady, bool productionKeysReady, IReadOnlyList<NativeLaunchCandidate> candidates,
|
||||
DateTimeOffset? checkedAt = null, bool vaultBacked = false)
|
||||
{
|
||||
var now = checkedAt ?? DateTimeOffset.UtcNow;
|
||||
if (!isSwitchGame)
|
||||
return Unavailable(NativeRemotePlayState.UnsupportedPlatform,
|
||||
"Only Nintendo Switch games can use the isolated Eden player.");
|
||||
if (!runtimeReady)
|
||||
return Unavailable(NativeRemotePlayState.RuntimeUnavailable,
|
||||
"The isolated Eden runtime or exact-title controller is not available.");
|
||||
if (!productionKeysReady)
|
||||
return Unavailable(NativeRemotePlayState.MissingKeys,
|
||||
"Select your own prod.keys in Settings before starting this game.");
|
||||
if (candidates.Count == 0)
|
||||
return Unavailable(NativeRemotePlayState.MissingGame,
|
||||
"No linked XCI or NSP was found for this game.");
|
||||
|
||||
var selected = SelectBaseGame(candidates);
|
||||
if (selected is null)
|
||||
{
|
||||
if (!candidates.Any(candidate => candidate.SourceReadOnly))
|
||||
return Unavailable(NativeRemotePlayState.SourceNotReadOnly,
|
||||
"The linked Switch source is not mounted read-only, so playback is blocked.");
|
||||
if (EligibleBaseGames(candidates).Length == 0)
|
||||
return Unavailable(NativeRemotePlayState.MissingBaseGame,
|
||||
"Linked Switch files were found, but none is a qualified base game. Add the base XCI or NSP; updates and DLC cannot start alone.");
|
||||
return Unavailable(NativeRemotePlayState.AmbiguousMapping,
|
||||
"Ludarium could not identify one unique read-only base XCI or NSP for this game.");
|
||||
}
|
||||
|
||||
return NativeRemotePlayPolicy.Available(Player, gameId,
|
||||
"The exact linked base game is ready in the isolated Eden player.", now, "switch", vaultBacked);
|
||||
|
||||
NativeRemotePlayCapability Unavailable(NativeRemotePlayState state, string message) =>
|
||||
NativeRemotePlayPolicy.Unavailable(Player, gameId, state, message, now,
|
||||
isSwitchGame ? "switch" : null);
|
||||
}
|
||||
|
||||
private static (NativeLaunchCandidate Candidate, Match Match)[] EligibleBaseGames(
|
||||
IReadOnlyList<NativeLaunchCandidate> candidates) => candidates
|
||||
.Where(candidate => candidate.SourceReadOnly && candidate.Size > MinimumImageBytes)
|
||||
.Select(candidate => (Candidate: candidate, Match: TitleIdentity().Match(candidate.RelativePath)))
|
||||
.Where(item => item.Match.Success && item.Match.Groups["titleId"].Value.EndsWith("000", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
public static string ToRuntimePath(string relativePath, string catalogPrefix) =>
|
||||
NativeRemotePlayPolicy.ToRuntimePath(relativePath, catalogPrefix, Player.Extensions);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"OpenMcdf": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.2.0, )",
|
||||
"resolved": "3.2.0",
|
||||
"contentHash": "n/iojS7V77YjM6IBbXaP0ZI8EELhJ2j6XRjx9NxMyUtjcM4a0yr0fWqO1y7lgd3CgYwnDugaKlO9c7Os8VxNtg=="
|
||||
},
|
||||
"SharpCompress": {
|
||||
"type": "Direct",
|
||||
"requested": "[0.50.4, )",
|
||||
"resolved": "0.50.4",
|
||||
"contentHash": "/hxjUR7DEX6mky8/LQXyrnrKioOL6D6veAID1EZpro+q4s02x5dHEYBV3qjEN6lDYYilDoQQ76BcmQj+lRx51w=="
|
||||
},
|
||||
"System.IO.Hashing": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.11, )",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "OzKDcIRkeNJeC8qAsbn8yJXnfTLP1dtkWILe+T56Gf/z+IkAASi7sMqLqJQat08j5z/mRN5xVtoAwbkMNMoBUQ=="
|
||||
},
|
||||
"ludarium.domain": {
|
||||
"type": "Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,227 @@
|
||||
namespace Ludarium.Domain;
|
||||
|
||||
public enum LibraryKind { Rom, WindowsPackage, DiscImage, Mixed, Unsorted }
|
||||
public enum HashPolicy { Sha256, CatalogCompatible, OnDemand }
|
||||
public enum ScanMode { Quick, Deep, Integrity }
|
||||
public enum ScanState { Queued, Verifying, Discovering, Processing, Paused, Cancelling, Completed, Failed, Cancelled }
|
||||
public enum ArtifactState { Present, Missing, LibraryUnavailable, Ignored, Superseded }
|
||||
public enum MediaType { Rom, DiscDescriptor, DiscImage, Archive, WindowsPackage, Document, SupportFile, Unknown }
|
||||
public enum Confidence { None, Low, Medium, High, Deterministic }
|
||||
public enum BundleKind { SingleRom, CueBin, MultiDisc, WindowsInstaller, ArchiveGroup }
|
||||
public enum BundleState { Complete, CompleteWithWarnings, Incomplete, Ambiguous, Unverified }
|
||||
public enum ReviewState { Open, Deferred, Resolved }
|
||||
public enum ReviewBulkAction { ClassifySupportContent, AcceptPlatform, ExcludeFolderPattern }
|
||||
public enum Severity { Info, Notice, Warning, Critical }
|
||||
public enum JobState { Queued, Leased, Paused, Completed, Failed, DeadLetter, Cancelled }
|
||||
public enum ArtworkRole { VerifiedBoxFront, BoxFront, Poster, Banner, Screenshot, GeneratedFallback, UserUpload }
|
||||
public enum ArtworkVerificationStatus { Unverified, AutomaticallyVerified, ManuallyVerified, NeedsReview, Rejected }
|
||||
public enum WishlistPriority { Low, Normal, High }
|
||||
public enum WishlistStatus { Interested, WaitingForRelease, WaitingForSale, Reserved, Acquired }
|
||||
public enum CollectionKind { Static, Smart, Virtual }
|
||||
public enum GamePlayStatus { None, Backlog, Playing, Paused, Completed, Abandoned }
|
||||
public enum GameRelationKind { Dlc, Mod, Hack, Patch, Translation, Expansion, Manual, Extra, Sequel, Prequel }
|
||||
public enum GameMediaKind { Screenshot, Manual, Background, Logo, Video, Soundtrack }
|
||||
public enum GameDataKind { Save, SaveState }
|
||||
public enum ReleaseOrigin { Scan, Manual }
|
||||
public enum GameOrigin { Scan, Manual }
|
||||
public enum BrowserPlayState { Available, Disabled, UnsupportedPlatform, UnsupportedFormat, MissingRom, MissingCore, EmulatorUnavailable, AmbiguousMapping, CapacityReached, Expired, Cancelled, Failed }
|
||||
public enum PlaySessionState { Starting, Ready, Active, Completed, Cancelled, Expired, Failed }
|
||||
public enum FirmwareAssetKind { Bios, Firmware, KeySet }
|
||||
|
||||
public sealed record LibraryRoot(
|
||||
Guid Id, string Name, string Path, LibraryKind Kind, bool Recursive, HashPolicy HashPolicy,
|
||||
bool InspectArchives, int MaxConcurrency, bool Enabled, bool ReadOnlyRequired,
|
||||
bool? IsReadOnly = null, bool IsAvailable = false, DateTimeOffset? LastVerifiedAt = null,
|
||||
string[]? Exclusions = null, long Version = 1, bool? HasEntries = null)
|
||||
{
|
||||
public static LibraryRoot Create(string name, string path, LibraryKind kind, bool recursive = true,
|
||||
HashPolicy hashPolicy = HashPolicy.Sha256, bool inspectArchives = true, int maxConcurrency = 1)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Library name is required.", nameof(name));
|
||||
if (string.IsNullOrWhiteSpace(path) || !System.IO.Path.IsPathFullyQualified(path))
|
||||
throw new ArgumentException("Library path must be absolute.", nameof(path));
|
||||
return new(Guid.NewGuid(), name.Trim(), System.IO.Path.GetFullPath(path), kind, recursive, hashPolicy,
|
||||
inspectArchives, Math.Clamp(maxConcurrency, 1, 8), true, true);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record LibraryHealthSummary(Guid LibraryId, long Artifacts, long Games, long Bytes,
|
||||
IReadOnlyList<string> DetectedPlatforms, DateTimeOffset? LastFullScan, DateTimeOffset? LastIncrementalScan);
|
||||
|
||||
public sealed record Artifact(
|
||||
Guid Id, Guid LibraryId, string RelativePath, long Size, DateTimeOffset ModifiedAt,
|
||||
ArtifactState State, MediaType MediaType, Confidence Confidence, Guid? ContentBlobId,
|
||||
DateTimeOffset FirstSeenAt, DateTimeOffset LastSeenAt, string? Signature,
|
||||
string? Platform, long Version = 1, string? Sha256 = null);
|
||||
|
||||
public sealed record ArtifactOverride(Guid ArtifactId, MediaType? MediaType, string? Platform, bool Ignore,
|
||||
string Actor, DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record ContentBlob(Guid Id, long Size, string Sha256, string? Sha1, string? Crc32,
|
||||
DateTimeOffset VerifiedAt);
|
||||
|
||||
public sealed record ArchiveMember(Guid Id, Guid ParentArtifactId, int Sequence, string Path, long CompressedSize,
|
||||
long UncompressedSize, bool IsDirectory, bool UnsafePath, Guid? ContentBlobId);
|
||||
|
||||
public sealed record Evidence(string Kind, string Value, string Source, string SourceVersion,
|
||||
Confidence Confidence, DateTimeOffset ObservedAt);
|
||||
|
||||
public sealed record MetadataClaim(Guid Id, string EntityType, Guid EntityId, string Field, string Value,
|
||||
string SourceType, string SourceId, Confidence Confidence, IReadOnlyList<Evidence> Evidence,
|
||||
bool ManualLock, DateTimeOffset CreatedAt, Guid? SupersedesId = null);
|
||||
|
||||
public sealed record BundleMember(Guid ArtifactId, string Role, int Sequence, bool Required, string Evidence);
|
||||
|
||||
public sealed record Bundle(Guid Id, BundleKind Kind, string Name, BundleState State,
|
||||
IReadOnlyList<BundleMember> Members, IReadOnlyList<string> Findings, long Version = 1);
|
||||
|
||||
public sealed record ReviewItem(Guid Id, string Reason, Severity Severity, ReviewState State,
|
||||
string PayloadJson, DateTimeOffset CreatedAt, DateTimeOffset? ResolvedAt = null,
|
||||
string? Resolution = null);
|
||||
|
||||
public sealed record ReviewGroup(string Key, string Category, string Reason, Severity Severity,
|
||||
long Count, long EstimatedBytes, string? Library, string? Platform, string PathPattern,
|
||||
DateTimeOffset OldestAt, DateTimeOffset NewestAt, string RecommendedAction,
|
||||
IReadOnlyList<Guid> ReviewIds, string? Resolution = null, DateTimeOffset? ResolvedAt = null,
|
||||
long? TotalCount = null);
|
||||
|
||||
public sealed record ReviewOperation(Guid Id, IReadOnlyList<ReviewItem> Before,
|
||||
string Resolution, string Actor, DateTimeOffset CreatedAt, DateTimeOffset? UndoneAt = null,
|
||||
IReadOnlyList<Artifact>? BeforeArtifacts = null, ReviewBulkAction? BulkAction = null,
|
||||
IReadOnlyList<LibraryRoot>? BeforeLibraries = null, string? ScopePattern = null,
|
||||
IReadOnlyList<ArtifactOverride>? BeforeOverrides = null,
|
||||
IReadOnlyList<ReviewItem>? After = null, IReadOnlyList<Artifact>? AfterArtifacts = null,
|
||||
IReadOnlyList<LibraryRoot>? AfterLibraries = null, IReadOnlyList<ArtifactOverride>? AfterOverrides = null,
|
||||
IReadOnlyList<long>? AddedEvidenceIds = null);
|
||||
|
||||
public sealed record Finding(Guid Id, string Category, Severity Severity, string Message,
|
||||
Guid? ArtifactId, Guid? BundleId, DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record Game(Guid Id, string Title, DateTimeOffset CreatedAt, long Version = 1,
|
||||
GameOrigin Origin = GameOrigin.Scan);
|
||||
public sealed record WishlistItem(Guid Id, Guid? GameId, string Title, string? Platform,
|
||||
WishlistPriority Priority, string? Notes, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt,
|
||||
long Version = 1, WishlistStatus Status = WishlistStatus.Interested, decimal? DesiredPrice = null,
|
||||
decimal? CurrentPrice = null, string? Currency = null, string? StoreName = null, string? StoreUrl = null,
|
||||
string? Edition = null, string? Region = null, DateTimeOffset? ReleaseDate = null,
|
||||
string? ExternalProvider = null, string? ExternalId = null, string? ArtworkUrl = null);
|
||||
public sealed record WishlistInput(Guid? GameId, string Title, string? Platform, WishlistPriority Priority,
|
||||
string? Notes, WishlistStatus Status = WishlistStatus.Interested, decimal? DesiredPrice = null,
|
||||
decimal? CurrentPrice = null, string? Currency = null, string? StoreName = null, string? StoreUrl = null,
|
||||
string? Edition = null, string? Region = null, DateTimeOffset? ReleaseDate = null,
|
||||
string? ExternalProvider = null, string? ExternalId = null, string? ArtworkUrl = null);
|
||||
public sealed record GameArtwork(Guid Id, Guid GameId, ArtworkRole Role, ArtworkVerificationStatus VerificationStatus,
|
||||
string Source, string? Provider, string? ExternalId, string MatchMethod, Confidence Confidence,
|
||||
int? Width, int? Height, decimal? AspectRatio, long Length, string Sha256, decimal QualityScore,
|
||||
DateTimeOffset RetrievedAt, bool Selected = true, string? Platform = null);
|
||||
public sealed record ArtworkReviewItem(Guid GameId, string Title, GameArtwork Artwork);
|
||||
public sealed record BackgroundJob(Guid Id, string Kind, JobState State, string PayloadJson,
|
||||
long ItemsCompleted, long? ItemsTotal, int Attempts, int MaxAttempts, DateTimeOffset ScheduledAt,
|
||||
DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, string? LeaseOwner = null,
|
||||
DateTimeOffset? LeaseUntil = null, bool CancellationRequested = false, string? Error = null);
|
||||
public sealed record ProviderSnapshot(Guid Id, string Provider, string Version, string State, string? ETag,
|
||||
string Sha256, DateTimeOffset RetrievedAt, DateTimeOffset ExpiresAt, DateTimeOffset? LastSuccessfulSyncAt,
|
||||
string? Error = null);
|
||||
public sealed record Release(Guid Id, Guid GameId, string Title, string? Platform, string? Region,
|
||||
string? Revision, DateTimeOffset CreatedAt, long Version = 1, ReleaseOrigin Origin = ReleaseOrigin.Scan);
|
||||
public sealed record CollectionRule(string? Query = null, string? Platform = null, string[]? Tags = null,
|
||||
GamePlayStatus? Status = null, bool? Favorite = null, string? MetadataField = null,
|
||||
string? MetadataValue = null);
|
||||
public sealed record GameGroup(Guid Id, string Name, string? Description, CollectionKind Kind,
|
||||
CollectionRule? Rule, bool Pinned, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt,
|
||||
long Version = 1, long GameCount = 0);
|
||||
public sealed record GameTag(Guid Id, Guid GameId, string Name, string NormalizedName, string SourceType,
|
||||
string SourceId, Confidence Confidence, bool ManualLock, DateTimeOffset CreatedAt);
|
||||
public sealed record TagSummary(string Name, string NormalizedName, long GameCount);
|
||||
public sealed record GameUserState(Guid GameId, bool Favorite, GamePlayStatus Status, int? Rating,
|
||||
int? Difficulty, decimal? CompletionPercent, string? Notes, int PlayCount,
|
||||
DateTimeOffset? LastPlayedAt, DateTimeOffset UpdatedAt, long Version = 1)
|
||||
{
|
||||
public static GameUserState Empty(Guid gameId) => new(gameId, false, GamePlayStatus.None, null,
|
||||
null, null, null, 0, null, DateTimeOffset.UtcNow, 0);
|
||||
}
|
||||
public sealed record RecentlyPlayedGame(Game Game, GameUserState State);
|
||||
public sealed record GameRelation(Guid Id, Guid GameId, GameRelationKind Kind, string Title,
|
||||
Guid? RelatedGameId, Guid? ArtifactId, string SourceType, string SourceId, Confidence Confidence,
|
||||
bool ManualLock, DateTimeOffset CreatedAt);
|
||||
public sealed record GameMedia(Guid Id, Guid GameId, GameMediaKind Kind, string Title, string Source,
|
||||
string? Provider, string? ExternalId, string? Location, string? ContentType, long? Length,
|
||||
string? Sha256, bool AppOwned, bool Selected, DateTimeOffset CreatedAt);
|
||||
public sealed record Achievement(Guid Id, string ExternalId, string Title, string? Description,
|
||||
int Points, bool Unlocked, DateTimeOffset? UnlockedAt, string? BadgeUrl);
|
||||
public sealed record GameAchievementProgress(Guid GameId, string Provider, string ExternalGameId,
|
||||
int Earned, int Total, DateTimeOffset UpdatedAt, IReadOnlyList<Achievement> Achievements);
|
||||
public sealed record PlatformDefinition(string Id, string Name, string Category, string[] Aliases,
|
||||
bool MetadataOnly, bool Custom, bool Enabled, DateTimeOffset UpdatedAt, long Version = 1);
|
||||
public sealed record GameDataEntry(Guid Id, Guid GameId, GameDataKind Kind, string Name,
|
||||
string? Emulator, string? Device, string? Notes, Guid CurrentRevisionId, int RevisionCount,
|
||||
DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, long Version = 1);
|
||||
public sealed record GameDataRevision(Guid Id, Guid EntryId, int Sequence, string FileName,
|
||||
string ContentType, long Length, string Sha256, string Location, string SourceType,
|
||||
string SourceId, DateTimeOffset CreatedAt);
|
||||
public sealed record GameDataItem(GameDataEntry Entry, GameDataRevision CurrentRevision);
|
||||
public sealed record GameDataSummary(long Saves, long SaveStates, long Revisions, long Bytes);
|
||||
public sealed record BrowserPlayCandidate(Guid GameId, Guid ArtifactId, string GameTitle, string Platform,
|
||||
string RelativePath, long Size, string? Sha256, bool SourceReadOnly);
|
||||
public sealed record BrowserPlayCapability(Guid GameId, bool Available, BrowserPlayState State,
|
||||
string Message, string? Platform = null, string? Emulator = null, string? Core = null,
|
||||
bool DesktopRecommended = false, DateTimeOffset? CheckedAt = null, bool AutomaticRestore = false);
|
||||
public sealed record BrowserPlaySession(Guid Id, Guid GameId, Guid ArtifactId, PlaySessionState State,
|
||||
string Platform, string Emulator, string Core, string LaunchUrl, string TokenHash,
|
||||
DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt, DateTimeOffset? EndedAt = null,
|
||||
string? ErrorCode = null, string? ErrorMessage = null);
|
||||
public sealed record FirmwareAsset(Guid Id, string Platform, string Slot, FirmwareAssetKind Kind,
|
||||
string FileName, long Length, string Sha256, string Location, bool Selected,
|
||||
DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, long Version = 1);
|
||||
public sealed record CatalogSource(Guid Id, string Name, string Format, string Version, string? Sha256,
|
||||
string State, DateTimeOffset ImportedAt, int EntryCount);
|
||||
public sealed record CatalogEntry(Guid Id, Guid SourceId, string GameName, string RomName, long? Size,
|
||||
string? Sha256, string? Sha1, string? Crc32, string Identity);
|
||||
public sealed record MatchScoreComponent(string Kind, decimal Weight, string Evidence);
|
||||
public sealed record MatchCandidate(Guid Id, Guid ArtifactId, Guid CatalogEntryId, decimal Score,
|
||||
IReadOnlyList<MatchScoreComponent> Components, string State, DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record ScanRun(Guid Id, Guid LibraryId, ScanMode Mode, ScanState State, string Stage,
|
||||
long ItemsCompleted, long? ItemsTotal, long BytesCompleted, long? BytesTotal,
|
||||
string? Checkpoint, bool CancellationRequested, DateTimeOffset CreatedAt,
|
||||
DateTimeOffset? StartedAt = null, DateTimeOffset? FinishedAt = null, string? Error = null,
|
||||
long NewItems = 0, long ChangedItems = 0, long MovedItems = 0, long DeletedItems = 0)
|
||||
{
|
||||
public ScanRun Transition(ScanState next, string stage)
|
||||
{
|
||||
var allowed = State switch
|
||||
{
|
||||
ScanState.Queued => next is ScanState.Verifying or ScanState.Cancelled,
|
||||
ScanState.Verifying => next is ScanState.Discovering or ScanState.Failed or ScanState.Cancelling,
|
||||
ScanState.Discovering => next is ScanState.Processing or ScanState.Paused or ScanState.Cancelling or ScanState.Failed,
|
||||
ScanState.Processing => next is ScanState.Completed or ScanState.Paused or ScanState.Cancelling or ScanState.Failed,
|
||||
ScanState.Paused => next is ScanState.Discovering or ScanState.Processing or ScanState.Cancelling,
|
||||
ScanState.Cancelling => next is ScanState.Cancelled,
|
||||
_ => false
|
||||
};
|
||||
if (!allowed) throw new InvalidOperationException($"Invalid scan transition {State} -> {next}.");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return this with
|
||||
{
|
||||
State = next,
|
||||
Stage = stage,
|
||||
StartedAt = StartedAt ?? now,
|
||||
FinishedAt = next is ScanState.Completed or ScanState.Failed or ScanState.Cancelled ? now : null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record IntegritySnapshot(Guid Id, string Name, string SchemaVersion, string ApplicationVersion,
|
||||
DateTimeOffset CreatedAt, IReadOnlyList<SnapshotItem> Items);
|
||||
|
||||
public sealed record SnapshotItem(Guid LibraryId, string RelativePath, long Size, string Sha256,
|
||||
Guid ArtifactId, Guid? BundleId);
|
||||
|
||||
public enum SnapshotChangeKind { Added, Removed, Moved, ContentChanged, Unchanged }
|
||||
public sealed record SnapshotChange(SnapshotChangeKind Kind, SnapshotItem? Before, SnapshotItem? After, string Explanation);
|
||||
|
||||
public sealed record AuditEvent(Guid Id, string Actor, string Action, string TargetType, Guid TargetId,
|
||||
string? BeforeJson, string? AfterJson, Guid CorrelationId, DateTimeOffset CreatedAt);
|
||||
|
||||
public sealed record ManualResolution(Guid Id, Guid ReviewItemId, string Action, string Actor,
|
||||
string BeforeJson, string AfterJson, DateTimeOffset CreatedAt, DateTimeOffset? UndoneAt = null);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,15 @@
|
||||
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(4) };
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await client.GetAsync("http://127.0.0.1:8734/health/ready");
|
||||
return response.IsSuccessStatusCode ? 0 : 1;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,142 @@
|
||||
import { chromium } from "playwright";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:1230";
|
||||
const token = process.env.LUDARIUM_ADMIN_TOKEN;
|
||||
if (!token) throw new Error("LUDARIUM_ADMIN_TOKEN is required.");
|
||||
const routes = ["Home", "Library", "Wishlist", "Attention", "Activity", "Settings"];
|
||||
const viewports = [
|
||||
{ name: "mobile-320", width: 320, height: 720 },
|
||||
{ name: "mobile-360", width: 360, height: 800 },
|
||||
{ name: "desktop", width: 1440, height: 900 },
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
{ name: "mobile-430", width: 430, height: 932 },
|
||||
{ name: "tablet-880", width: 880, height: 900 },
|
||||
{ name: "desktop-1280", width: 1280, height: 800 },
|
||||
{ name: "desktop-1920", width: 1920, height: 1080 },
|
||||
{ name: "ultrawide-3440", width: 3440, height: 1440 },
|
||||
];
|
||||
const themes = ["dark", "light"];
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const failures = [];
|
||||
const waitForApplication = async (page) => {
|
||||
try {
|
||||
await page.locator("main").waitFor({ state: "visible", timeout: 60_000 });
|
||||
} catch (error) {
|
||||
const snapshot = await page.locator("body").innerText().catch(() => "<body unavailable>");
|
||||
throw new Error(
|
||||
`Ludarium did not render its main landmark at ${page.url()}. Body: ${snapshot.slice(0, 500)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
};
|
||||
const overflowNodes = (page) => page.evaluate(() => [...document.querySelectorAll("body *")]
|
||||
.filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.right > document.documentElement.clientWidth + 1 || rect.left < -1;
|
||||
})
|
||||
.slice(0, 12)
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return `${element.tagName.toLowerCase()}.${[...element.classList].join(".")} [${Math.round(rect.left)},${Math.round(rect.right)}] ${(element.textContent ?? "").trim().slice(0, 36)}`;
|
||||
}));
|
||||
try {
|
||||
for (const viewport of viewports) {
|
||||
for (const theme of themes) {
|
||||
const context = await browser.newContext({
|
||||
viewport,
|
||||
reducedMotion: "reduce",
|
||||
colorScheme: theme,
|
||||
});
|
||||
// The application stores an explicit preference, so the gate sets the same one a person would.
|
||||
await context.addInitScript((value) => localStorage.setItem("ludarium.theme", value), theme);
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${baseUrl}/#Home`, { waitUntil: "networkidle" });
|
||||
await waitForApplication(page);
|
||||
const login = page.getByLabel("Administrator token");
|
||||
if (await login.isVisible()) {
|
||||
await login.fill(token);
|
||||
await page.getByRole("button", { name: "Open Ludarium" }).click();
|
||||
await login.waitFor({ state: "hidden" });
|
||||
await page.locator("main h1").waitFor({ state: "visible", timeout: 60_000 });
|
||||
}
|
||||
for (const route of routes) {
|
||||
await page.goto(`${baseUrl}/#${route}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector("h1");
|
||||
if (await page.evaluate(() => document.documentElement.scrollWidth !== document.documentElement.clientWidth))
|
||||
failures.push({ viewport: viewport.name, theme, route, id: "horizontal-overflow", nodes: await overflowNodes(page) });
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
for (const violation of results.violations)
|
||||
failures.push({
|
||||
viewport: viewport.name,
|
||||
theme,
|
||||
route,
|
||||
id: violation.id,
|
||||
impact: violation.impact,
|
||||
nodes: violation.nodes.map((n) => ({
|
||||
target: n.target,
|
||||
failure: n.failureSummary,
|
||||
})),
|
||||
});
|
||||
}
|
||||
await page.goto(`${baseUrl}/#Library`, { waitUntil: "networkidle" });
|
||||
await page.locator(".game-card").first().click();
|
||||
for (const violation of (
|
||||
await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze()
|
||||
).violations)
|
||||
failures.push({
|
||||
viewport: viewport.name,
|
||||
theme,
|
||||
route: "Game drawer",
|
||||
id: violation.id,
|
||||
nodes: violation.nodes.map((n) => n.target),
|
||||
});
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await page.getByRole("button", { name: /search (?:games|ludarium)/i }).click();
|
||||
for (const violation of (
|
||||
await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze()
|
||||
).violations)
|
||||
failures.push({
|
||||
viewport: viewport.name,
|
||||
theme,
|
||||
route: "Search dialog",
|
||||
id: violation.id,
|
||||
nodes: violation.nodes.map((n) => n.target),
|
||||
});
|
||||
// The theme actually applied must be the one asked for, or the run proves nothing.
|
||||
const applied = await page.evaluate(() => document.documentElement.dataset.theme);
|
||||
if (applied !== theme)
|
||||
failures.push({ viewport: viewport.name, theme, route: "theme", id: "theme-not-applied", nodes: [applied] });
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
// A 640 CSS-pixel viewport represents a 1280-pixel window at 200% browser zoom.
|
||||
const zoomContext = await browser.newContext({ viewport: { width: 640, height: 800 }, reducedMotion: "reduce" });
|
||||
const zoomPage = await zoomContext.newPage();
|
||||
await zoomPage.goto(`${baseUrl}/#Library`, { waitUntil: "networkidle" });
|
||||
await waitForApplication(zoomPage);
|
||||
const zoomLogin = zoomPage.getByLabel("Administrator token");
|
||||
if (await zoomLogin.isVisible()) {
|
||||
await zoomLogin.fill(token);
|
||||
await zoomPage.getByRole("button", { name: "Open Ludarium" }).click();
|
||||
await zoomLogin.waitFor({ state: "hidden" });
|
||||
await zoomPage.locator("main h1").waitFor({ state: "visible", timeout: 60_000 });
|
||||
}
|
||||
if (await zoomPage.evaluate(() => document.documentElement.scrollWidth !== document.documentElement.clientWidth))
|
||||
failures.push({ viewport: "desktop-200-percent-zoom", route: "Library", id: "horizontal-overflow", nodes: await overflowNodes(zoomPage) });
|
||||
await zoomContext.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (failures.length) {
|
||||
console.error(JSON.stringify(failures, null, 2));
|
||||
process.exitCode = 1;
|
||||
} else
|
||||
console.log(
|
||||
`Accessibility gate passed: ${routes.length} routes and key overlays across ${viewports.length} viewports in ${themes.join(" and ")}.`,
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { chromium } from "playwright";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:4173";
|
||||
const output = resolve(process.cwd(), "../../artifacts/visual-audit");
|
||||
await mkdir(output, { recursive: true });
|
||||
|
||||
const platforms = [
|
||||
{ platform: "switch", games: 73, artifacts: 2240, bytes: 824_000_000_000 },
|
||||
{ platform: "nds", games: 67, artifacts: 980, bytes: 33_000_000_000 },
|
||||
{ platform: "windows", games: 41, artifacts: 3210, bytes: 1_170_000_000_000 },
|
||||
{ platform: "ps5", games: 28, artifacts: 1370, bytes: 993_800_000_000 },
|
||||
{ platform: "psx", games: 21, artifacts: 420, bytes: 19_000_000_000 },
|
||||
{ platform: "n64", games: 18, artifacts: 210, bytes: 760_000_000 },
|
||||
{ platform: "snes", games: 16, artifacts: 190, bytes: 490_000_000 },
|
||||
{ platform: "gba", games: 15, artifacts: 170, bytes: 330_000_000 },
|
||||
{ platform: "psvita", games: 14, artifacts: 540, bytes: 58_000_000_000 },
|
||||
{ platform: "gamecube", games: 13, artifacts: 380, bytes: 24_000_000_000 },
|
||||
];
|
||||
|
||||
const status = {
|
||||
version: "visual-review",
|
||||
schemaVersion: 27,
|
||||
libraries: 3,
|
||||
artifacts: 13_856,
|
||||
presentArtifacts: 13_856,
|
||||
games: 347,
|
||||
releases: 351,
|
||||
bundles: 0,
|
||||
totalBytes: 3_670_000_000_000,
|
||||
hashedArtifacts: 0,
|
||||
recognizedArtifacts: 13_500,
|
||||
unknownArtifacts: 356,
|
||||
missingArtifacts: 0,
|
||||
duplicateArtifacts: 0,
|
||||
duplicateBytes: 0,
|
||||
openReviews: 0,
|
||||
criticalFindings: 0,
|
||||
activeScans: 0,
|
||||
rootsRequiringAttention: 0,
|
||||
excludedSupportGames: 0,
|
||||
unresolvedGameCandidates: 0,
|
||||
lastFullScanAt: "2026-08-12T08:30:00Z",
|
||||
lastIncrementalScanAt: "2026-08-12T08:30:00Z",
|
||||
catalogImportedWithoutScan: false,
|
||||
confidencePercent: 98.6,
|
||||
};
|
||||
|
||||
function payloadFor(pathname) {
|
||||
if (pathname.endsWith("/system/status")) return status;
|
||||
if (pathname.endsWith("/platforms")) return platforms;
|
||||
if (pathname.endsWith("/system/storage")) return { libraries: [
|
||||
{ name: "Games", bytes: 2_676_200_000_000 },
|
||||
{ name: "PS4 Games", bytes: 0 },
|
||||
{ name: "PS5 Games", bytes: 993_800_000_000 },
|
||||
] };
|
||||
if (pathname.endsWith("/wishlist/summary")) return { total: 0, upcoming: 0 };
|
||||
if (pathname.includes("/wishlist")) return { items: [], total: 0, page: 1, pageSize: 3 };
|
||||
return [];
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const viewport of [
|
||||
{ name: "wide", width: 1920, height: 1080, captureFull: false },
|
||||
{ name: "desktop", width: 1440, height: 1000, captureFull: true },
|
||||
{ name: "tablet", width: 768, height: 900, captureFull: false },
|
||||
{ name: "mobile", width: 390, height: 844, captureFull: true },
|
||||
{ name: "mobile-320", width: 320, height: 720, captureFull: false },
|
||||
]) {
|
||||
const context = await browser.newContext({ viewport, reducedMotion: "reduce", colorScheme: "dark" });
|
||||
await context.addInitScript(() => {
|
||||
sessionStorage.setItem("ludarium.adminToken", "visual-review-token");
|
||||
localStorage.setItem("ludarium.language", "nl");
|
||||
localStorage.setItem("ludarium.theme", "dark");
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const consoleErrors = [];
|
||||
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(payloadFor(new URL(route.request().url()).pathname)) });
|
||||
});
|
||||
await page.goto(`${baseUrl}/#Home`, { waitUntil: "networkidle" });
|
||||
const atlas = page.locator(".archive-atlas");
|
||||
await atlas.waitFor({ state: "visible" });
|
||||
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
if (overflow > 1) throw new Error(`${viewport.name}: document overflows by ${overflow}px`);
|
||||
const tiles = atlas.locator(".archive-atlas-map li:visible");
|
||||
const expectedTiles = viewport.width <= 700 ? 5 : 7;
|
||||
if (await tiles.count() !== expectedTiles) throw new Error(`${viewport.name}: expected ${expectedTiles} visible atlas regions, found ${await tiles.count()}`);
|
||||
|
||||
await tiles.nth(1).locator("a").focus();
|
||||
const selectedLabel = await tiles.nth(1).locator(".archive-atlas-tile-copy b").innerText();
|
||||
if (await atlas.locator(".archive-atlas-ledger h3").innerText() !== selectedLabel) throw new Error(`${viewport.name}: keyboard focus did not update the archive ledger`);
|
||||
|
||||
const axe = await new AxeBuilder({ page }).include(".home-atlas-section").withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();
|
||||
if (axe.violations.length) throw new Error(`${viewport.name}: accessibility violations: ${axe.violations.map((item) => item.id).join(", ")}`);
|
||||
if (consoleErrors.length) throw new Error(`${viewport.name}: console errors: ${consoleErrors.join(" | ")}`);
|
||||
|
||||
await page.locator(".home-atlas-section").screenshot({ path: resolve(output, `archive-atlas-${viewport.name}.png`) });
|
||||
if (viewport.captureFull) {
|
||||
await page.evaluate(() => window.scrollTo({ top: 0, left: 0, behavior: "auto" }));
|
||||
await page.screenshot({ path: resolve(output, `archive-atlas-home-${viewport.name}.png`), fullPage: true });
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
console.log(`Archive Atlas visual, overflow, keyboard and accessibility checks passed. Screenshots: ${output}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium, request } from "playwright";
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:1230";
|
||||
const token = process.env.LUDARIUM_ADMIN_TOKEN;
|
||||
if (!token) throw new Error("LUDARIUM_ADMIN_TOKEN is required.");
|
||||
|
||||
const errors = [];
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const api = await request.newContext({
|
||||
baseURL: `${baseUrl}/api/v1/`,
|
||||
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
try {
|
||||
const title = `Vault browser fixture ${Date.now()}`;
|
||||
const created = await api.post("games", { data: { title } });
|
||||
assert.equal(created.status(), 201, await created.text());
|
||||
const game = await created.json();
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.on("console", (message) => message.type() === "error" && errors.push(message.text()));
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto(`${baseUrl}/#Library`, { waitUntil: "networkidle" });
|
||||
const login = page.getByLabel("Administrator token");
|
||||
await page.waitForFunction(() =>
|
||||
document.querySelector('.game-browser, .empty-state') !== null ||
|
||||
document.body.innerText.includes('Administrator token'));
|
||||
if (await login.isVisible()) {
|
||||
await login.fill(token);
|
||||
await page.getByRole("button", { name: "Open Ludarium" }).click();
|
||||
await login.waitFor({ state: "hidden" });
|
||||
await page.goto(`${baseUrl}/#Library`, { waitUntil: "networkidle" });
|
||||
}
|
||||
|
||||
await page.getByLabel("Search games").fill(title);
|
||||
const card = page.locator(".game-card", { hasText: title });
|
||||
await card.waitFor();
|
||||
await card.click();
|
||||
await page.locator(".drawer").waitFor();
|
||||
await page.getByRole("tab", { name: "Saves & states" }).click();
|
||||
|
||||
const vault = page.locator(".detail-section", {
|
||||
has: page.getByRole("heading", { name: "Game Data Vault" }),
|
||||
});
|
||||
await vault.getByLabel("Name").fill("Main campaign");
|
||||
await vault.getByLabel("Emulator or core").fill("mGBA");
|
||||
await vault.getByLabel("Device").fill("Steam Deck");
|
||||
await vault.getByLabel("Notes").fill("Browser release fixture");
|
||||
const firstPayload = Buffer.from("SYNTHETIC-SAVE|browser|progress=42");
|
||||
await vault.getByLabel(/Save or state file/).setInputFiles({
|
||||
name: "browser-slot.sav",
|
||||
mimeType: "application/octet-stream",
|
||||
buffer: firstPayload,
|
||||
});
|
||||
await vault.getByRole("button", { name: "Add to vault" }).click();
|
||||
await vault.getByText(/stored as revision 1/i).waitFor();
|
||||
|
||||
const vaultCard = vault.locator(".vault-card", { hasText: "Main campaign" });
|
||||
await vaultCard.getByText("browser-slot.sav").waitFor();
|
||||
assert.match((await vaultCard.locator(".vault-evidence code").textContent()) ?? "", /^[a-f0-9]{12}…$/);
|
||||
|
||||
const firstDownloadPromise = page.waitForEvent("download");
|
||||
await vaultCard.getByRole("button", { name: "Download current" }).click();
|
||||
const firstDownload = await firstDownloadPromise;
|
||||
assert.equal(firstDownload.suggestedFilename(), "browser-slot.sav");
|
||||
const firstStream = await firstDownload.createReadStream();
|
||||
const firstChunks = [];
|
||||
for await (const chunk of firstStream) firstChunks.push(chunk);
|
||||
assert.deepEqual(Buffer.concat(firstChunks), firstPayload);
|
||||
|
||||
const secondPayload = Buffer.from("SYNTHETIC-SAVE|browser|progress=99");
|
||||
await vaultCard.locator('input[type="file"]').setInputFiles({
|
||||
name: "browser-slot.sav",
|
||||
mimeType: "application/octet-stream",
|
||||
buffer: secondPayload,
|
||||
});
|
||||
await vaultCard.getByText(/Revision 2 stored/i).waitFor();
|
||||
await vaultCard.getByText("2 revisions · immutable history").click();
|
||||
await vaultCard.getByText("Revision 2", { exact: true }).waitFor();
|
||||
assert.equal(await vaultCard.locator(".vault-revision").count(), 2);
|
||||
|
||||
await vaultCard.getByRole("button", { name: "Edit details" }).click();
|
||||
await vaultCard.getByLabel("Device").fill("Living room handheld");
|
||||
await vaultCard.getByRole("button", { name: "Save details" }).click();
|
||||
await vaultCard.getByText(/metadata updated/i).waitFor();
|
||||
const desktopA11y = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
assert.equal(desktopA11y.violations.length, 0, JSON.stringify(desktopA11y.violations));
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
assert.equal(
|
||||
await page.locator(".drawer").evaluate((element) => Math.round(element.getBoundingClientRect().width)),
|
||||
390,
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth === document.documentElement.clientWidth),
|
||||
true,
|
||||
);
|
||||
const mobileA11y = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
assert.equal(mobileA11y.violations.length, 0, JSON.stringify(mobileA11y.violations));
|
||||
|
||||
await vaultCard.getByRole("button", { name: "Delete" }).click();
|
||||
// Deleting an entry asks in the application's own dialog, whose button names the action.
|
||||
const removeEntry = page.getByRole("alertdialog");
|
||||
await removeEntry.waitFor();
|
||||
await removeEntry.getByRole("button", { name: "Delete vault entry" }).click();
|
||||
await removeEntry.waitFor({ state: "detached" });
|
||||
await vaultCard.waitFor({ state: "detached" });
|
||||
await vault.getByText("The vault is empty").waitFor();
|
||||
const summary = await api.get(`games/${game.id}/data/summary`);
|
||||
assert.equal(summary.status(), 200, await summary.text());
|
||||
assert.deepEqual(await summary.json(), { saves: 0, saveStates: 0, revisions: 0, bytes: 0 });
|
||||
assert.equal(errors.length, 0, `Console errors: ${errors.join("; ")}`);
|
||||
await context.close();
|
||||
console.log("Game Data Vault browser gate passed: create, evidence, download, revision, history, edit, mobile reflow and delete.");
|
||||
} finally {
|
||||
await api.dispose();
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL;
|
||||
const token = process.env.LUDARIUM_ADMIN_TOKEN;
|
||||
const gameId = process.env.BROWSERPLAY_GAME_ID;
|
||||
const expectedPlatform = process.env.BROWSERPLAY_PLATFORM;
|
||||
const expectedCore = process.env.BROWSERPLAY_CORE;
|
||||
const expectedBytes = Number(process.env.BROWSERPLAY_BYTES);
|
||||
const expectedMagic = (process.env.BROWSERPLAY_MAGIC ?? "").split(",").filter(Boolean).map(value => Number.parseInt(value, 16));
|
||||
const evidencePath = process.env.BROWSERPLAY_EVIDENCE_PATH;
|
||||
const restoreEvidencePath = process.env.BROWSERPLAY_RESTORE_EVIDENCE_PATH;
|
||||
const startTimeout = Number(process.env.BROWSERPLAY_START_TIMEOUT ?? 90_000);
|
||||
assert.ok(baseUrl && token && gameId && expectedPlatform && expectedCore && expectedBytes > 0,
|
||||
"core gate environment is incomplete");
|
||||
|
||||
const apiHeaders = { Authorization: `Bearer ${token}` };
|
||||
const capability = await (await fetch(`${baseUrl}/api/v1/games/${gameId}/play-capability`, { headers: apiHeaders })).json();
|
||||
assert.equal(capability.available, true, JSON.stringify(capability));
|
||||
assert.equal(capability.platform, expectedPlatform);
|
||||
assert.equal(capability.core, expectedCore);
|
||||
assert.equal(capability.automaticRestore, true);
|
||||
|
||||
const startSession = async () => {
|
||||
const response = await fetch(`${baseUrl}/api/v1/games/${gameId}/play-sessions`, { method: "POST", headers: apiHeaders });
|
||||
assert.equal(response.status, 201);
|
||||
return response.json();
|
||||
};
|
||||
const cancelSession = session => fetch(`${baseUrl}/api/v1/play-sessions/${session.id}`, { method: "DELETE", headers: apiHeaders });
|
||||
const session = await startSession();
|
||||
const launch = new URL(session.launchUrl, baseUrl);
|
||||
const capabilityToken = new URLSearchParams(launch.hash.slice(1)).get("token");
|
||||
const contentUrl = `${baseUrl}/api/v1/play-sessions/${session.id}/content`;
|
||||
assert.equal((await fetch(contentUrl)).status, 404);
|
||||
const range = await fetch(`${contentUrl}?token=${capabilityToken}`, { headers: { Range: "bytes=0-15" } });
|
||||
assert.equal(range.status, 206);
|
||||
assert.equal(range.headers.get("content-range"), `bytes 0-15/${expectedBytes}`);
|
||||
assert.deepEqual([...new Uint8Array(await range.arrayBuffer())].slice(0, expectedMagic.length), expectedMagic);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const consoleErrors = [];
|
||||
const requestFailures = [];
|
||||
const externalRequests = [];
|
||||
const expectedOrigin = new URL(baseUrl).origin;
|
||||
page.on("console", message => {
|
||||
if (message.type() !== "error") return;
|
||||
const location = message.location().url;
|
||||
if (location.endsWith("/favicon.ico")) return;
|
||||
consoleErrors.push(`${location || "console"}: ${message.text()}`);
|
||||
});
|
||||
page.on("requestfailed", request => requestFailures.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`));
|
||||
page.on("request", request => {
|
||||
const url = new URL(request.url());
|
||||
if ((url.protocol === "http:" || url.protocol === "https:") && url.origin !== expectedOrigin)
|
||||
externalRequests.push(`${request.method()} ${request.url()}`);
|
||||
});
|
||||
try {
|
||||
await page.goto(launch.href, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#status").waitFor({ state: "detached", timeout: startTimeout });
|
||||
await page.locator("#game canvas").waitFor({ state: "visible", timeout: 60_000 });
|
||||
await page.waitForFunction(() => typeof window.EJS_emulator?.gameManager?.getState === "function", null, { timeout: 60_000 });
|
||||
await page.waitForTimeout(2_000);
|
||||
const stateUpload = page.waitForResponse(response => response.request().method() === "PUT" &&
|
||||
response.url().includes(`/play-sessions/${session.id}/data/SaveState`), { timeout: 60_000 });
|
||||
const stateBytes = await page.evaluate(() => {
|
||||
const state = window.EJS_emulator.gameManager.getState();
|
||||
if (!(state instanceof Uint8Array) || state.byteLength === 0) throw new Error("core returned no savestate bytes");
|
||||
window.EJS_onSaveState({ state });
|
||||
return state.byteLength;
|
||||
});
|
||||
assert.ok(stateBytes > 0);
|
||||
assert.equal((await stateUpload).status(), 200);
|
||||
if (evidencePath) await page.screenshot({ path: evidencePath, fullPage: true });
|
||||
assert.equal((await cancelSession(session)).status, 204);
|
||||
|
||||
const restoredSession = await startSession();
|
||||
const restoredLaunch = new URL(restoredSession.launchUrl, baseUrl);
|
||||
const restoreDownload = page.waitForResponse(response => response.request().method() === "GET" &&
|
||||
response.url().includes(`/play-sessions/${restoredSession.id}/data/SaveState`), { timeout: 60_000 });
|
||||
try {
|
||||
await page.goto(restoredLaunch.href, { waitUntil: "domcontentloaded" });
|
||||
assert.equal((await restoreDownload).status(), 200);
|
||||
await page.locator("#status").waitFor({ state: "detached", timeout: 90_000 });
|
||||
await page.locator("#game canvas").waitFor({ state: "visible", timeout: 60_000 });
|
||||
await page.waitForFunction(() => document.body.dataset.restoreComplete === "true" &&
|
||||
document.body.dataset.restoredState === "true", null, { timeout: 60_000 });
|
||||
if (restoreEvidencePath) await page.screenshot({ path: restoreEvidencePath, fullPage: true });
|
||||
} finally {
|
||||
await cancelSession(restoredSession);
|
||||
}
|
||||
assert.equal(externalRequests.length, 0, `player made external requests:\n${externalRequests.join("\n")}`);
|
||||
assert.equal(requestFailures.length, 0, requestFailures.join("\n"));
|
||||
assert.equal(consoleErrors.length, 0, consoleErrors.join("\n"));
|
||||
console.log(`Core restore gate passed platform=${expectedPlatform} core=${expectedCore} ROM=${expectedBytes} state=${stateBytes}.`);
|
||||
} catch (error) {
|
||||
const statusText = await page.locator("#status").textContent().catch(() => "status element unavailable");
|
||||
const diagnostics = await page.evaluate(() => ({
|
||||
emulator: Boolean(window.EJS_emulator),
|
||||
gameManager: Boolean(window.EJS_emulator?.gameManager),
|
||||
crossOriginIsolated: window.crossOriginIsolated,
|
||||
sharedArrayBuffer: typeof SharedArrayBuffer,
|
||||
webgl2: Boolean(document.createElement("canvas").getContext("webgl2")),
|
||||
emulatorKeys: window.EJS_emulator ? Object.keys(window.EJS_emulator).filter(key => /ready|load|start|core|game|error|text|config/i.test(key)) : [],
|
||||
emulatorState: window.EJS_emulator ? {
|
||||
core: window.EJS_emulator.getCore?.(),
|
||||
system: window.EJS_emulator.config?.system,
|
||||
failedToStart: window.EJS_emulator.failedToStart,
|
||||
started: window.EJS_emulator.started,
|
||||
text: window.EJS_emulator.textElem?.innerText
|
||||
} : null,
|
||||
canvases: document.querySelectorAll("canvas").length,
|
||||
scripts: [...document.scripts].map(script => script.src),
|
||||
resources: performance.getEntriesByType("resource").map(entry => ({ name: entry.name, duration: Math.round(entry.duration) }))
|
||||
.filter(entry => /emulatorjs|play-sessions/.test(entry.name))
|
||||
})).catch(() => ({ diagnostics: "page unavailable" }));
|
||||
throw new Error(`${error.message}\nplayer status: ${statusText}\ndiagnostics: ${JSON.stringify(diagnostics)}\nconsole errors:\n${consoleErrors.join("\n")}\nrequest failures:\n${requestFailures.join("\n")}`,
|
||||
{ cause: error });
|
||||
} finally {
|
||||
await browser.close();
|
||||
await cancelSession(session);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL;
|
||||
const token = process.env.LUDARIUM_ADMIN_TOKEN;
|
||||
const gameId = process.env.BROWSERPLAY_GAME_ID;
|
||||
const expectedBytes = Number(process.env.BROWSERPLAY_BYTES);
|
||||
const evidencePath = process.env.BROWSERPLAY_EVIDENCE_PATH;
|
||||
const restoreEvidencePath = process.env.BROWSERPLAY_RESTORE_EVIDENCE_PATH;
|
||||
assert.ok(baseUrl && token && gameId && expectedBytes > 0, "N64 gate environment is incomplete");
|
||||
|
||||
const apiHeaders = { Authorization: `Bearer ${token}` };
|
||||
const capability = await (await fetch(`${baseUrl}/api/v1/games/${gameId}/play-capability`, { headers: apiHeaders })).json();
|
||||
assert.equal(capability.available, true, JSON.stringify(capability));
|
||||
assert.equal(capability.platform, "n64");
|
||||
assert.equal(capability.core, "n64wasm");
|
||||
assert.equal(capability.emulator, "Ludarium N64Wasm");
|
||||
assert.equal(capability.automaticRestore, true);
|
||||
|
||||
const startSession = async () => {
|
||||
const response = await fetch(`${baseUrl}/api/v1/games/${gameId}/play-sessions`, { method: "POST", headers: apiHeaders });
|
||||
assert.equal(response.status, 201);
|
||||
return response.json();
|
||||
};
|
||||
const cancel = session => fetch(`${baseUrl}/api/v1/play-sessions/${session.id}`, { method: "DELETE", headers: apiHeaders });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const errors = [];
|
||||
const failures = [];
|
||||
page.on("console", message => { if (message.type() === "error") errors.push(message.text()); });
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
page.on("requestfailed", request => failures.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`));
|
||||
let firstSession;
|
||||
let restoredSession;
|
||||
try {
|
||||
firstSession = await startSession();
|
||||
const launch = new URL(firstSession.launchUrl, baseUrl);
|
||||
assert.match(launch.pathname, /\/n64-player\.html$/);
|
||||
const capabilityToken = new URLSearchParams(launch.hash.slice(1)).get("token");
|
||||
const contentUrl = `${baseUrl}/api/v1/play-sessions/${firstSession.id}/content`;
|
||||
assert.equal((await fetch(contentUrl)).status, 404);
|
||||
const range = await fetch(`${contentUrl}?token=${capabilityToken}`, { headers: { Range: "bytes=0-15" } });
|
||||
assert.ok([200, 206].includes(range.status), `Unexpected ROM response ${range.status}`);
|
||||
if (range.status === 206) {
|
||||
assert.equal(range.headers.get("content-range"), `bytes 0-15/${expectedBytes}`);
|
||||
}
|
||||
const probe = new Uint8Array(await range.arrayBuffer());
|
||||
if (range.status === 200) assert.equal(probe.byteLength, expectedBytes);
|
||||
const magic = [...probe.slice(0, 4)];
|
||||
assert.ok([
|
||||
[0x80, 0x37, 0x12, 0x40],
|
||||
[0x37, 0x80, 0x40, 0x12],
|
||||
[0x40, 0x12, 0x37, 0x80]
|
||||
].some(candidate => candidate.every((value, index) => value === magic[index])),
|
||||
`Invalid N64 byte-order magic ${magic.map(value => value.toString(16).padStart(2, "0")).join("")}`);
|
||||
|
||||
await page.goto(launch.href, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => document.body.dataset.started === "true", null, { timeout: 90_000 });
|
||||
await page.locator("#canvas").waitFor({ state: "visible" });
|
||||
const firstEvidence = await page.evaluate(() => ({
|
||||
isolated: crossOriginIsolated,
|
||||
webgl2: Boolean(document.getElementById("canvas")?.getContext("webgl2")),
|
||||
width: document.getElementById("canvas")?.width,
|
||||
height: document.getElementById("canvas")?.height,
|
||||
romBytes: window.LudariumN64?.romBytes
|
||||
}));
|
||||
assert.deepEqual(firstEvidence, { isolated: true, webgl2: true, width: 640, height: 480, romBytes: expectedBytes });
|
||||
|
||||
const stateUpload = page.waitForResponse(response => response.request().method() === "PUT" &&
|
||||
response.url().includes(`/play-sessions/${firstSession.id}/data/SaveState`), { timeout: 60_000 });
|
||||
await page.locator("#save-state").click();
|
||||
assert.equal((await stateUpload).status(), 200);
|
||||
await page.waitForFunction(() => document.body.dataset.stateSaved === "true", null, { timeout: 10_000 });
|
||||
if (evidencePath) await page.screenshot({ path: evidencePath, fullPage: true });
|
||||
assert.equal((await cancel(firstSession)).status, 204);
|
||||
|
||||
restoredSession = await startSession();
|
||||
const restoredLaunch = new URL(restoredSession.launchUrl, baseUrl);
|
||||
const restoreDownload = page.waitForResponse(response => response.request().method() === "GET" &&
|
||||
response.url().includes(`/play-sessions/${restoredSession.id}/data/SaveState`), { timeout: 60_000 });
|
||||
await page.goto(restoredLaunch.href, { waitUntil: "domcontentloaded" });
|
||||
assert.equal((await restoreDownload).status(), 200);
|
||||
await page.waitForFunction(() => document.body.dataset.started === "true" &&
|
||||
document.body.dataset.restoredState === "true", null, { timeout: 90_000 });
|
||||
if (restoreEvidencePath) await page.screenshot({ path: restoreEvidencePath, fullPage: true });
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(failures, []);
|
||||
console.log(`N64Wasm gate passed ROM=${expectedBytes} canvas=640x480 with bound savestate restore.`);
|
||||
} catch (error) {
|
||||
const diagnostics = await page.evaluate(() => ({
|
||||
status: document.getElementById("status")?.innerText,
|
||||
started: document.body.dataset.started,
|
||||
restored: document.body.dataset.restoredState,
|
||||
stateSaved: document.body.dataset.stateSaved,
|
||||
runtime: Boolean(window.LudariumN64),
|
||||
resources: performance.getEntriesByType("resource").map(entry => entry.name).filter(name => /n64wasm|play-sessions/.test(name))
|
||||
})).catch(() => ({ page: "unavailable" }));
|
||||
throw new Error(`${error.message}\ndiagnostics=${JSON.stringify(diagnostics)}\nerrors=${errors.join("\n")}\nfailures=${failures.join("\n")}`, { cause: error });
|
||||
} finally {
|
||||
if (restoredSession) await cancel(restoredSession);
|
||||
if (firstSession) await cancel(firstSession);
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL;
|
||||
const token = process.env.LUDARIUM_ADMIN_TOKEN;
|
||||
const gameId = process.env.BROWSERPLAY_GAME_ID;
|
||||
const expectedBytes = Number(process.env.BROWSERPLAY_BYTES);
|
||||
const startTimeout = Number(process.env.BROWSERPLAY_START_TIMEOUT || 120_000);
|
||||
assert.ok(baseUrl && token && gameId && expectedBytes > 0, "PS2 gate environment is incomplete");
|
||||
|
||||
const apiHeaders = { Authorization: `Bearer ${token}` };
|
||||
const capability = await (await fetch(`${baseUrl}/api/v1/games/${gameId}/play-capability`, { headers: apiHeaders })).json();
|
||||
assert.equal(capability.available, true, JSON.stringify(capability));
|
||||
assert.equal(capability.platform, "ps2");
|
||||
assert.equal(capability.core, "playjs");
|
||||
assert.equal(capability.emulator, "Ludarium Play!.js");
|
||||
assert.equal(capability.automaticRestore, false);
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/v1/games/${gameId}/play-sessions`, { method: "POST", headers: apiHeaders });
|
||||
assert.equal(response.status, 201);
|
||||
const session = await response.json();
|
||||
assert.match(session.launchUrl, /^\/ps2-player\.html\?/);
|
||||
const launch = new URL(session.launchUrl, baseUrl);
|
||||
const capabilityToken = new URLSearchParams(launch.hash.slice(1)).get("token");
|
||||
const contentUrl = `${baseUrl}/api/v1/play-sessions/${session.id}/content`;
|
||||
assert.equal((await fetch(contentUrl)).status, 404);
|
||||
const range = await fetch(`${contentUrl}?token=${capabilityToken}`, { headers: { Range: "bytes=0-15" } });
|
||||
assert.equal(range.status, 206);
|
||||
assert.equal(range.headers.get("content-range"), `bytes 0-15/${expectedBytes}`);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const errors = [];
|
||||
page.on("console", message => { if (message.type() === "error") errors.push(message.text()); });
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
try {
|
||||
await page.goto(launch.href, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => document.body.dataset.started === "true", null, { timeout: startTimeout });
|
||||
await page.locator("#outputCanvas").waitFor({ state: "visible" });
|
||||
const evidence = await page.evaluate(() => ({
|
||||
isolated: crossOriginIsolated,
|
||||
frames: Number(document.body.dataset.frames),
|
||||
size: window.LudariumPlay?.size,
|
||||
failed: Boolean(window.LudariumPlay?.disc?.failed),
|
||||
width: document.getElementById("outputCanvas")?.width,
|
||||
height: document.getElementById("outputCanvas")?.height
|
||||
}));
|
||||
assert.equal(evidence.isolated, true);
|
||||
assert.ok(evidence.frames > 0, JSON.stringify(evidence));
|
||||
assert.equal(evidence.size, expectedBytes);
|
||||
assert.equal(evidence.failed, false);
|
||||
assert.ok(evidence.width > 0 && evidence.height > 0);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.equal((await fetch(`${baseUrl}/api/v1/play-sessions/${session.id}`, { method: "DELETE", headers: apiHeaders })).status, 204);
|
||||
console.log(`PS2 Play!.js gate passed ROM=${expectedBytes} frames=${evidence.frames} canvas=${evidence.width}x${evidence.height}.`);
|
||||
} catch (error) {
|
||||
const diagnostics = await page.evaluate(() => ({
|
||||
status: document.getElementById("status")?.innerText,
|
||||
started: document.body.dataset.started,
|
||||
isolated: crossOriginIsolated,
|
||||
play: Boolean(window.LudariumPlay),
|
||||
frames: window.LudariumPlay?.module?.getFrames?.(),
|
||||
failed: String(window.LudariumPlay?.disc?.failed || ""),
|
||||
resources: performance.getEntriesByType("resource").map(entry => entry.name)
|
||||
.filter(name => /playjs|play-sessions/.test(name))
|
||||
})).catch(() => ({ page: "unavailable" }));
|
||||
throw new Error(`${error.message}\ndiagnostics=${JSON.stringify(diagnostics)}\nerrors=${errors.join("\n")}`, { cause: error });
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fetch(`${baseUrl}/api/v1/play-sessions/${session.id}`, { method: "DELETE", headers: apiHeaders });
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL;
|
||||
const token = process.env.LUDARIUM_ADMIN_TOKEN;
|
||||
const gameId = process.env.BROWSERPLAY_GAME_ID;
|
||||
const evidencePath = process.env.BROWSERPLAY_EVIDENCE_PATH ?? "/evidence/live-browserplay.png";
|
||||
const restoreEvidencePath = process.env.BROWSERPLAY_RESTORE_EVIDENCE_PATH ?? "/evidence/live-browserplay-restore.png";
|
||||
assert.ok(baseUrl && token && gameId, "live browser-play gate environment is incomplete");
|
||||
|
||||
const apiHeaders = { Authorization: `Bearer ${token}` };
|
||||
const capabilityResponse = await fetch(`${baseUrl}/api/v1/games/${gameId}/play-capability`, { headers: apiHeaders });
|
||||
assert.equal(capabilityResponse.status, 200);
|
||||
const capability = await capabilityResponse.json();
|
||||
assert.equal(capability.available, true, JSON.stringify(capability));
|
||||
assert.equal(capability.platform, "nes");
|
||||
assert.equal(capability.core, "fceumm");
|
||||
assert.equal(capability.automaticRestore, true);
|
||||
|
||||
const startSession = async () => {
|
||||
const response = await fetch(`${baseUrl}/api/v1/games/${gameId}/play-sessions`, {
|
||||
method: "POST",
|
||||
headers: apiHeaders
|
||||
});
|
||||
assert.equal(response.status, 201);
|
||||
const value = await response.json();
|
||||
assert.match(value.launchUrl, /^\/player\.html\?[^#]+#token=[a-f0-9]{64}$/);
|
||||
return value;
|
||||
};
|
||||
const cancelSession = session => fetch(`${baseUrl}/api/v1/play-sessions/${session.id}`, {
|
||||
method: "DELETE", headers: apiHeaders
|
||||
});
|
||||
|
||||
const session = await startSession();
|
||||
|
||||
const launch = new URL(session.launchUrl, baseUrl);
|
||||
const contentUrl = `${baseUrl}/api/v1/play-sessions/${session.id}/content`;
|
||||
assert.equal((await fetch(contentUrl)).status, 404, "content must fail closed without the capability token");
|
||||
const capabilityToken = new URLSearchParams(launch.hash.slice(1)).get("token");
|
||||
const rangeResponse = await fetch(`${contentUrl}?token=${capabilityToken}`, { headers: { Range: "bytes=0-15" } });
|
||||
assert.equal(rangeResponse.status, 206);
|
||||
assert.equal(rangeResponse.headers.get("content-range"), "bytes 0-15/24592");
|
||||
assert.deepEqual([...new Uint8Array(await rangeResponse.arrayBuffer())].slice(0, 4), [0x4e, 0x45, 0x53, 0x1a]);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const consoleErrors = [];
|
||||
const requestFailures = [];
|
||||
page.on("console", message => { if (message.type() === "error") consoleErrors.push(message.text()); });
|
||||
page.on("requestfailed", request => requestFailures.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`));
|
||||
try {
|
||||
await page.goto(launch.href, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#status").waitFor({ state: "detached", timeout: 60_000 });
|
||||
await page.locator("#game canvas").waitFor({ state: "visible", timeout: 30_000 });
|
||||
await page.waitForTimeout(2_000);
|
||||
await page.screenshot({ path: evidencePath, fullPage: true });
|
||||
await page.waitForFunction(() => typeof window.EJS_emulator?.gameManager?.getState === "function");
|
||||
const stateUpload = page.waitForResponse(response =>
|
||||
response.request().method() === "PUT" && response.url().includes(`/play-sessions/${session.id}/data/SaveState`));
|
||||
await page.evaluate(() => {
|
||||
const state = window.EJS_emulator.gameManager.getState();
|
||||
if (!(state instanceof Uint8Array) || state.byteLength === 0) throw new Error("EmulatorJS returned no savestate bytes");
|
||||
window.EJS_onSaveState({ state });
|
||||
});
|
||||
assert.equal((await stateUpload).status(), 200);
|
||||
assert.equal((await cancelSession(session)).status, 204);
|
||||
|
||||
const restoredSession = await startSession();
|
||||
const restoredLaunch = new URL(restoredSession.launchUrl, baseUrl);
|
||||
const restoreDownload = page.waitForResponse(response =>
|
||||
response.request().method() === "GET" && response.url().includes(`/play-sessions/${restoredSession.id}/data/SaveState`));
|
||||
try {
|
||||
await page.goto(restoredLaunch.href, { waitUntil: "domcontentloaded" });
|
||||
assert.equal((await restoreDownload).status(), 200);
|
||||
await page.locator("#status").waitFor({ state: "detached", timeout: 60_000 });
|
||||
await page.locator("#game canvas").waitFor({ state: "visible", timeout: 30_000 });
|
||||
await page.waitForFunction(() => document.body.dataset.restoreComplete === "true" &&
|
||||
document.body.dataset.restoredState === "true");
|
||||
await page.screenshot({ path: restoreEvidencePath, fullPage: true });
|
||||
} finally {
|
||||
await cancelSession(restoredSession);
|
||||
}
|
||||
assert.equal(requestFailures.length, 0, requestFailures.join("\n"));
|
||||
assert.equal(consoleErrors.length, 0, consoleErrors.join("\n"));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await cancelSession(session);
|
||||
}
|
||||
|
||||
console.log(`Live native browser-play and bound savestate restore passed: game=${gameId} core=fceumm ROM bytes=24592.`);
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL;
|
||||
const token = process.env.LUDARIUM_ADMIN_TOKEN;
|
||||
const gameId = process.env.SWITCH_GAME_ID;
|
||||
const evidencePath = process.env.SWITCH_EVIDENCE_PATH;
|
||||
assert.ok(baseUrl && token && gameId, "Switch player gate environment is incomplete");
|
||||
|
||||
const authorization = { Authorization: `Bearer ${token}` };
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
const failures = [];
|
||||
let sockets = 0;
|
||||
let session;
|
||||
|
||||
page.on("console", message => { if (message.type() === "error") errors.push(message.text()); });
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
page.on("requestfailed", request => failures.push(`${request.method()} ${request.url()}: ${request.failure()?.errorText}`));
|
||||
page.on("websocket", () => { sockets += 1; });
|
||||
|
||||
try {
|
||||
const created = await context.request.post(`${baseUrl}/api/v1/games/${gameId}/switch-player-sessions`, {
|
||||
headers: authorization
|
||||
});
|
||||
assert.equal(created.status(), 201, await created.text());
|
||||
session = await created.json();
|
||||
|
||||
const navigation = await page.goto(session.launchUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||
assert.equal(navigation?.status(), 200);
|
||||
await page.locator("video, canvas").first().waitFor({ state: "attached", timeout: 60_000 });
|
||||
await page.waitForTimeout(5_000);
|
||||
|
||||
const surface = await page.evaluate(() => ({
|
||||
title: document.title,
|
||||
videos: document.querySelectorAll("video").length,
|
||||
canvases: document.querySelectorAll("canvas").length,
|
||||
passwordFields: document.querySelectorAll('input[type="password"]').length,
|
||||
text: document.body.innerText.slice(0, 1_000)
|
||||
}));
|
||||
assert.ok(surface.videos + surface.canvases > 0, JSON.stringify(surface));
|
||||
assert.equal(surface.passwordFields, 0, "Embedded player exposed a login form");
|
||||
assert.ok(!/sign in|log in|login/i.test(surface.text), "Embedded player exposed a login prompt");
|
||||
assert.ok(sockets > 0, "Selkies signalling WebSocket did not connect");
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(failures, []);
|
||||
if (process.env.SWITCH_REFRESH_GAME_LIST === "1") {
|
||||
// Eden's refresh control is rendered inside the streamed desktop rather than the browser DOM.
|
||||
// The validation viewport is fixed above, so this targets the lower-left refresh icon.
|
||||
await page.mouse.click(229, 574);
|
||||
await page.waitForTimeout(30_000);
|
||||
}
|
||||
if (evidencePath) await page.screenshot({ path: evidencePath, fullPage: true });
|
||||
|
||||
for (const action of ["pause-resume", "pause-resume", "fullscreen", "stop"]) {
|
||||
const controlled = await context.request.post(`${baseUrl}/api/v1/switch-player-sessions/${session.id}/actions`, {
|
||||
headers: authorization,
|
||||
data: { action }
|
||||
});
|
||||
assert.equal(controlled.status(), 202, `${action}: ${await controlled.text()}`);
|
||||
}
|
||||
|
||||
const ended = await context.request.delete(`${baseUrl}/api/v1/switch-player-sessions/${session.id}`, {
|
||||
headers: authorization
|
||||
});
|
||||
assert.equal(ended.status(), 204);
|
||||
session = undefined;
|
||||
assert.equal((await page.reload({ waitUntil: "domcontentloaded" }))?.status(), 401);
|
||||
console.log(`Embedded Switch gate passed media=${surface.videos + surface.canvases} websockets=${sockets}; revoked session returned 401.`);
|
||||
} catch (error) {
|
||||
const diagnostics = await page.evaluate(() => ({
|
||||
title: document.title,
|
||||
text: document.body.innerText.slice(0, 1_000),
|
||||
videos: document.querySelectorAll("video").length,
|
||||
canvases: document.querySelectorAll("canvas").length
|
||||
})).catch(() => ({ page: "unavailable" }));
|
||||
throw new Error(`${error.message}\ndiagnostics=${JSON.stringify(diagnostics)}\nerrors=${errors.join("\n")}\nfailures=${failures.join("\n")}`, { cause: error });
|
||||
} finally {
|
||||
if (session) {
|
||||
await context.request.delete(`${baseUrl}/api/v1/switch-player-sessions/${session.id}`, {
|
||||
headers: authorization
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createServer } from "node:http";
|
||||
|
||||
const port = Number(process.env.LUDARIUM_VISUAL_AUDIT_PORT ?? 8734);
|
||||
const now = "2026-08-23T18:00:00Z";
|
||||
const ids = Array.from({ length: 8 }, (_, index) => `00000000-0000-0000-0000-0000000000${String(index + 1).padStart(2, "0")}`);
|
||||
const titles = ["Gravity Circuit", "Sea of Stars", "Lumines", "Odin Sphere", "Tearaway", "Wipeout 2048", "Shovel Knight", "Child of Light"];
|
||||
const games = ids.map((id, index) => ({ id, title: titles[index], createdAt: now, version: 1, origin: "Scan" }));
|
||||
const releases = games.map((game, index) => ({ id: `10000000-0000-0000-0000-0000000000${String(index + 1).padStart(2, "0")}`, gameId: game.id, title: game.title, platform: "psvita", region: "Europe", revision: null, version: 1, origin: "Scan" }));
|
||||
const wishlistItems = games.slice(0, 3).map((game, index) => ({
|
||||
id: `40000000-0000-0000-0000-0000000000${String(index + 1).padStart(2, "0")}`, gameId: game.id,
|
||||
title: game.title, platform: "psvita", priority: index === 0 ? "High" : "Normal",
|
||||
notes: index === 0 ? "Collector's edition" : null, status: index === 2 ? "WaitingForSale" : "Interested",
|
||||
desiredPrice: index === 2 ? 25 : null, currentPrice: index === 2 ? 20 : null, currency: "EUR",
|
||||
storeName: null, storeUrl: null, edition: null, region: "Europe", releaseDate: null,
|
||||
externalProvider: null, externalId: null, artworkUrl: null, createdAt: now, updatedAt: now, version: 1,
|
||||
}));
|
||||
const platforms = [
|
||||
{ platform: "psvita", games: 8, artifacts: 12, bytes: 26_400_000_000 },
|
||||
{ platform: "gamecube", games: 6, artifacts: 9, bytes: 8_400_000_000 },
|
||||
{ platform: "xbox-series", games: 4, artifacts: 5, bytes: 315_000_000_000 },
|
||||
];
|
||||
const status = {
|
||||
version: "visual-audit", schemaVersion: 27, libraries: 3, artifacts: 26, presentArtifacts: 26,
|
||||
games: 18, releases: 18, bundles: 3, totalBytes: 349_800_000_000, hashedArtifacts: 24,
|
||||
recognizedArtifacts: 25, unknownArtifacts: 1, missingArtifacts: 1, duplicateArtifacts: 0,
|
||||
duplicateBytes: 0, openReviews: 2, criticalFindings: 0, activeScans: 0,
|
||||
rootsRequiringAttention: 0, excludedSupportGames: 0, unresolvedGameCandidates: 1,
|
||||
lastFullScanAt: "2026-08-22T18:00:00Z", lastIncrementalScanAt: "2026-08-23T17:30:00Z",
|
||||
catalogImportedWithoutScan: false, confidencePercent: 96.4,
|
||||
};
|
||||
|
||||
const review = (state) => ({
|
||||
id: `20000000-0000-0000-0000-0000000000${state === "Deferred" ? "02" : state === "Resolved" ? "03" : "01"}`,
|
||||
reason: state === "Resolved" ? "Unknown platform classification" : "Incomplete related file group",
|
||||
severity: state === "Open" ? "Warning" : "Notice", state, payloadJson: "{}", createdAt: now,
|
||||
resolvedAt: state === "Resolved" ? now : null,
|
||||
resolution: state === "Resolved" ? "Accepted PlayStation Vita after header verification" : null,
|
||||
});
|
||||
const group = (state) => {
|
||||
const item = review(state);
|
||||
return {
|
||||
key: `${state.toLowerCase()}-visual-group`, category: state === "Resolved" ? "unknown" : "incomplete",
|
||||
reason: item.reason, severity: item.severity, count: state === "Open" ? 2 : 1,
|
||||
estimatedBytes: 940_000_000, library: "Handhelds", platform: "psvita",
|
||||
pathPattern: "PS Vita/Review/*", oldestAt: now, newestAt: now,
|
||||
recommendedAction: state === "Resolved" ? "Inspect the recorded decision" : "Verify the companion files with a scan",
|
||||
reviewIds: [item.id], resolution: item.resolution, resolvedAt: item.resolvedAt, totalCount: state === "Open" ? 2 : 1,
|
||||
};
|
||||
};
|
||||
|
||||
function json(response, body, statusCode = 200) {
|
||||
response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function coverSvg(index) {
|
||||
const hues = [214, 167, 284, 24, 346, 198, 48, 252];
|
||||
const hue = hues[index % hues.length];
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 960"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="hsl(${hue} 78% 58%)"/><stop offset="1" stop-color="hsl(${(hue + 54) % 360} 62% 17%)"/></linearGradient></defs><rect width="750" height="960" rx="28" fill="url(#g)"/><path d="M80 710 275 380l118 170 96-135 181 295z" fill="#fff" opacity=".18"/><circle cx="565" cy="235" r="112" fill="#fff" opacity=".14"/><rect x="78" y="782" width="594" height="18" rx="9" fill="#fff" opacity=".72"/><rect x="78" y="825" width="402" height="12" rx="6" fill="#fff" opacity=".35"/></svg>`;
|
||||
}
|
||||
|
||||
createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
|
||||
const path = url.pathname.replace(/^\/api\/v1/, "");
|
||||
if (request.method === "OPTIONS") return json(response, {});
|
||||
if (path === "/system/status") return json(response, status);
|
||||
if (path === "/system/health") return json(response, { critical: 0, warning: 1, notice: 1, unknown: 1, missing: 1, incompleteBundles: 1, recentFindings: [] });
|
||||
if (path === "/platforms") return json(response, platforms);
|
||||
if (path === "/wishlist/summary") return json(response, { total: wishlistItems.length, highPriority: 1, upcoming: 0, priceDrops: 1 });
|
||||
if (path === "/wishlist") return json(response, { items: wishlistItems, pageNumber: 1, pageSize: 24, total: wishlistItems.length });
|
||||
if (/^\/wishlist\/[^/]+\/artwork$/.test(path)) return json(response, null, 404);
|
||||
if (path === "/collections" || path === "/tags" || path === "/system/remote-players") return json(response, path === "/system/remote-players" ? {} : []);
|
||||
if (path === "/games" && url.searchParams.get("pageSize") === "1") return json(response, { items: [], page: 1, pageSize: 1, total: games.length });
|
||||
if (path === "/games") return json(response, { items: games, page: 1, pageSize: 48, total: games.length });
|
||||
if (path === "/releases") {
|
||||
const gameId = url.searchParams.get("gameId");
|
||||
return json(response, gameId ? releases.filter((release) => release.gameId === gameId) : releases);
|
||||
}
|
||||
if (path === "/games/play-capabilities") return json(response, []);
|
||||
const gameDetail = path.match(/^\/games\/([^/]+)$/);
|
||||
if (request.method === "GET" && gameDetail) {
|
||||
const game = games.find((item) => item.id === gameDetail[1]);
|
||||
return game ? json(response, game) : json(response, { message: "Game not found" }, 404);
|
||||
}
|
||||
const detailResource = path.match(/^\/games\/([^/]+)\/(claims|artwork-info|artwork-candidates|tags|state|relations|media|achievements|data|play-capability)$/);
|
||||
if (request.method === "GET" && detailResource) {
|
||||
const [,, resource] = detailResource;
|
||||
if (resource === "artwork-info") return json(response, { source: "Libretro", contentType: "image/svg+xml", length: null });
|
||||
if (resource === "state") return json(response, {
|
||||
gameId: detailResource[1], favorite: false, status: "Backlog", rating: null, difficulty: null,
|
||||
completionPercent: null, notes: null, playCount: 0, lastPlayedAt: null, updatedAt: now, version: 1,
|
||||
});
|
||||
if (resource === "play-capability") return json(response, {
|
||||
gameId: detailResource[1], available: false, state: "UnsupportedPlatform",
|
||||
message: "No present ROM is linked to this game.", platform: "psvita",
|
||||
emulator: null, core: null, desktopRecommended: true, checkedAt: now, automaticRestore: false,
|
||||
});
|
||||
if (resource === "achievements") return json(response, null);
|
||||
return json(response, []);
|
||||
}
|
||||
const artwork = path.match(/^\/games\/([^/]+)\/artwork$/);
|
||||
if (artwork) {
|
||||
const index = Math.max(0, ids.indexOf(artwork[1]));
|
||||
response.writeHead(200, { "content-type": "image/svg+xml", "cache-control": "no-store" });
|
||||
return response.end(coverSvg(index));
|
||||
}
|
||||
if (path === "/review-items") {
|
||||
const state = url.searchParams.get("state") ?? "Open";
|
||||
const item = review(state);
|
||||
return json(response, { items: [item], page: 1, pageSize: 200, total: 1 });
|
||||
}
|
||||
if (path === "/review-groups") return json(response, [group(url.searchParams.get("state") ?? "Open")]);
|
||||
if (request.method === "POST" && ["/review-groups/defer", "/review-groups/reopen", "/review-groups/apply"].includes(path)) return json(response, { id: "30000000-0000-0000-0000-000000000001" });
|
||||
if (request.method === "POST" && (/^\/review-items\/.+\/(?:defer|reopen)$/.test(path) || /^\/review-operations\/.+\/undo$/.test(path))) return json(response, {});
|
||||
return json(response, []);
|
||||
}).listen(port, "127.0.0.1", () => console.log(`Visual audit API listening on http://127.0.0.1:${port}`));
|
||||
@@ -0,0 +1,501 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { chromium } from "playwright";
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:1230";
|
||||
const token = process.env.LUDARIUM_ADMIN_TOKEN;
|
||||
if (!token) throw new Error("LUDARIUM_ADMIN_TOKEN is required.");
|
||||
const screenshotDir = process.env.PLAYWRIGHT_SCREENSHOT_DIR;
|
||||
const mutateCatalog = process.env.PLAYWRIGHT_MUTATE_CATALOG !== "0";
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const errors = [];
|
||||
async function authenticate(page) {
|
||||
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.goto(`${baseUrl}/#Home`, { waitUntil: "networkidle" });
|
||||
await page.locator("main").waitFor({ state: "visible", timeout: 60_000 });
|
||||
const login = page.getByLabel("Administrator token");
|
||||
if (await login.isVisible()) {
|
||||
await login.fill(token);
|
||||
await page.getByRole("button", { name: "Open Ludarium" }).click();
|
||||
await login.waitFor({ state: "hidden" });
|
||||
}
|
||||
await page.locator(".home-status h1").waitFor();
|
||||
}
|
||||
async function api(path) {
|
||||
const response = await fetch(`${baseUrl}/api/v1${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
// One run spends about 550 reads of the API's 1200-per-minute budget, and the browser's own
|
||||
// requests share that budget, so a third run inside the same minute is refused. That is the
|
||||
// limiter working, not a defect: give it a minute.
|
||||
if (response.status === 429)
|
||||
throw new Error(`GET ${path} was rate limited. Wait a minute between runs against one host.`);
|
||||
if (!response.ok) throw new Error(`GET ${path} responded ${response.status}.`);
|
||||
return response.json();
|
||||
}
|
||||
/**
|
||||
* Finds a game the archive cannot play.
|
||||
*
|
||||
* The play panel's refusal is only a real assertion when the game genuinely has no playable copy.
|
||||
* Naming a title here tied the gate to one operator's archive, so it asks the catalogue instead.
|
||||
*/
|
||||
/** A refusal has to say what was wrong; an empty 400 tells a client nothing. */
|
||||
async function assertRefusalsCarryAMessage() {
|
||||
const response = await fetch(`${baseUrl}/api/v1/libraries`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: "gate", path: "/library/games", kind: "Rom", hashPolicy: "NotAPolicy" }),
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
const body = await response.json();
|
||||
assert.ok(body.message, "a request the API could not read was refused without saying why");
|
||||
}
|
||||
async function findUnplayableGame() {
|
||||
const catalog = await api("/games?pageSize=50");
|
||||
const games = catalog.items ?? catalog;
|
||||
assert.ok(games.length > 0, "The candidate archive holds no games to open.");
|
||||
const capabilities = await api(
|
||||
`/games/play-capabilities?ids=${games.map((game) => game.id).join(",")}`,
|
||||
);
|
||||
const blocked = new Set(capabilities.filter((entry) => !entry.available).map((entry) => entry.gameId));
|
||||
const game = games.find((candidate) => blocked.has(candidate.id));
|
||||
assert.ok(game, "Every game in the candidate archive is playable; this step needs one that is not.");
|
||||
return game;
|
||||
}
|
||||
async function shot(page, name) {
|
||||
if (!screenshotDir) return;
|
||||
await mkdir(screenshotDir, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: `${screenshotDir}/${name}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const desktop = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const page = await desktop.newPage();
|
||||
await authenticate(page);
|
||||
await page.evaluate(() => {
|
||||
const buttons = Array.from({ length: 17 }, () => ({ pressed: false, touched: false, value: 0 }));
|
||||
const pad = { axes: [0, 0, 0, 0], buttons, connected: true, id: "Ludarium workflow gamepad", index: 0, mapping: "standard", timestamp: 1, vibrationActuator: null };
|
||||
Object.defineProperty(navigator, "getGamepads", { configurable: true, value: () => [pad] });
|
||||
Object.defineProperty(window, "__ludariumWorkflowPad", { configurable: true, value: pad });
|
||||
const event = new Event("gamepadconnected");
|
||||
Object.defineProperty(event, "gamepad", { value: pad });
|
||||
dispatchEvent(event);
|
||||
});
|
||||
await page.locator(".controller-status").waitFor();
|
||||
assert.ok(await page.getByText("Controller ready").isVisible());
|
||||
await page.evaluate(() => { window.__ludariumWorkflowPad.buttons[13] = { pressed: true, touched: true, value: 1 }; });
|
||||
await page.waitForFunction(() => Boolean(document.activeElement?.closest("main .page")), undefined, { timeout: 2_000 });
|
||||
await page.evaluate(() => { window.__ludariumWorkflowPad.buttons[13] = { pressed: false, touched: false, value: 0 }; });
|
||||
assert.equal(await page.evaluate(() => Boolean(document.activeElement?.closest("main .page"))), true);
|
||||
await page.evaluate(() => { window.__ludariumWorkflowPad.buttons[9] = { pressed: true, touched: true, value: 1 }; });
|
||||
await page.getByRole("dialog", { name: "Search Ludarium" }).waitFor();
|
||||
await page.evaluate(() => { window.__ludariumWorkflowPad.buttons[9] = { pressed: false, touched: false, value: 0 }; });
|
||||
await page.waitForTimeout(40);
|
||||
await page.evaluate(() => { window.__ludariumWorkflowPad.buttons[1] = { pressed: true, touched: true, value: 1 }; });
|
||||
await page.getByRole("dialog", { name: "Search Ludarium" }).waitFor({ state: "hidden" });
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, "getGamepads", { configurable: true, value: () => [] });
|
||||
const event = new Event("gamepaddisconnected");
|
||||
Object.defineProperty(event, "gamepad", { value: window.__ludariumWorkflowPad });
|
||||
dispatchEvent(event);
|
||||
});
|
||||
await page.locator(".controller-status").waitFor({ state: "detached" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
assert.doesNotMatch(await page.locator(".home-status h1").innerText(), /\b1 items\b/i);
|
||||
// The primary sidebar stays focused on the eight top-level destinations. Deep archive and
|
||||
// playability tools remain available inside Library and Settings instead of duplicating routes.
|
||||
assert.equal(await page.locator("aside nav a").count(), 8);
|
||||
for (const name of ["Collections", "Platforms"])
|
||||
assert.ok(await page.locator(".app-shell > aside").getByRole("link", { name }).isVisible(), `${name} is missing`);
|
||||
// A configured archive offers a scan or a review; an empty one names the step that comes first.
|
||||
assert.ok(
|
||||
await page
|
||||
.getByRole("button", { name: /scan libraries|review priorities/i })
|
||||
.or(page.getByRole("link", { name: /view active scan|add a library/i }))
|
||||
.first()
|
||||
.isVisible(),
|
||||
);
|
||||
assert.equal(await page.locator(".metric").count(), 4);
|
||||
// Home's ceiling covers a full "Recently played" section plus the prioritized archive-health
|
||||
// signals. A third recent row or an unbounded attention list still exceeds this budget.
|
||||
const homeHasRecent = await page.locator(".home-recent").count() > 0;
|
||||
const homeBudget = homeHasRecent ? 1700 : 1380;
|
||||
const heroHeight = await page.locator(".home-status").evaluate((section) => section.getBoundingClientRect().height);
|
||||
const metricHeight = await page.locator(".metric-grid").evaluate((section) => section.getBoundingClientRect().height);
|
||||
assert.ok(heroHeight <= 440, `Home hero is ${heroHeight}px tall; its budget is 440px.`);
|
||||
assert.ok(metricHeight <= 140, `Home metrics are ${metricHeight}px tall; their budget is 140px.`);
|
||||
const homeHeight = await page.evaluate(() => document.documentElement.scrollHeight);
|
||||
assert.ok(homeHeight <= homeBudget, `Home is ${homeHeight}px tall; its budget is ${homeBudget}px.`);
|
||||
if (homeHasRecent)
|
||||
assert.ok(
|
||||
(await page.locator(".home-recent").evaluate((section) => section.getBoundingClientRect().height)) <= 320,
|
||||
"the recently-played section grew past the two rows its own limit allows",
|
||||
);
|
||||
await shot(page, "home-desktop");
|
||||
await assertRefusalsCarryAMessage();
|
||||
const unplayable = await findUnplayableGame();
|
||||
await page.getByRole("button", { name: /search games/i }).click();
|
||||
await page.getByLabel("Search games, files, paths or hashes").fill(unplayable.title);
|
||||
const option = page.getByRole("option").filter({ hasText: unplayable.title }).first();
|
||||
await option.waitFor();
|
||||
await option.click();
|
||||
await page.getByRole("heading", { name: "Library" }).waitFor();
|
||||
await page.locator(".drawer").waitFor();
|
||||
assert.equal(
|
||||
await page.locator(".detail-lead h3").evaluate((heading) => getComputedStyle(heading).color),
|
||||
"rgb(242, 247, 255)",
|
||||
"the light theme must not render the game title dark on the dark detail hero",
|
||||
);
|
||||
await shot(page, "game-detail-desktop");
|
||||
assert.equal(await page.getByRole("button", { name: "Play now" }).count(), 0);
|
||||
const playOptions = page.getByRole("region", { name: "Play options" });
|
||||
await playOptions.waitFor();
|
||||
assert.ok(await playOptions.isVisible());
|
||||
assert.ok(await playOptions.getByText("No playable copy available").isVisible());
|
||||
if (mutateCatalog) {
|
||||
const metadataValue = `Ludarium synthetic fixture studio ${Date.now()}`;
|
||||
await page.getByRole("button", { name: "Edit metadata" }).click();
|
||||
await page.getByLabel("Developer").fill(metadataValue);
|
||||
await page.getByRole("button", { name: "Save changes" }).click();
|
||||
await page.getByText(/manual field saved and locked/i).waitFor();
|
||||
await page.getByText("More details", { exact: true }).click();
|
||||
await page.getByRole("tab", { name: "Provenance" }).click();
|
||||
assert.ok(await page.getByText(/manually locked/i).first().isVisible());
|
||||
await page.getByRole("tab", { name: "Overview" }).click();
|
||||
assert.ok(await page.getByText(metadataValue).isVisible());
|
||||
}
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await page.goto(`${baseUrl}/#Library`, { waitUntil: "networkidle" });
|
||||
assert.ok(
|
||||
await page
|
||||
.getByRole("link", { name: "Games", exact: true })
|
||||
.getAttribute("class")
|
||||
.then((x) => x?.includes("active")),
|
||||
);
|
||||
const platformRailLink = page.locator('.library-platform-rail a[href*="platform="]').first();
|
||||
await platformRailLink.waitFor();
|
||||
await platformRailLink.click();
|
||||
assert.match(page.url(), /platform=/);
|
||||
await page.getByLabel("Active filters").waitFor();
|
||||
await page.waitForFunction(() => {
|
||||
const select = [...document.querySelectorAll("label.field")]
|
||||
.find((label) => label.textContent?.includes("Platform"))
|
||||
?.querySelector("select");
|
||||
return select instanceof HTMLSelectElement && select.value !== "all";
|
||||
});
|
||||
assert.notEqual(
|
||||
await page.locator("label.field").filter({ hasText: "Platform" }).locator("select").inputValue(),
|
||||
"all",
|
||||
);
|
||||
await page.getByRole("button", { name: "Reset filters" }).click();
|
||||
if (mutateCatalog) {
|
||||
const browserPlay = page.getByRole("button", { name: /play .+ in browser/i }).first();
|
||||
await browserPlay.waitFor();
|
||||
const playLabel = await browserPlay.getAttribute("aria-label");
|
||||
const playedTitle = playLabel?.replace(/^Play /, "").replace(/ in browser$/, "");
|
||||
assert.ok(playedTitle, "A browser-playable title is required for the recently-played gate.");
|
||||
await browserPlay.click();
|
||||
const player = page.getByRole("region", { name: new RegExp(`Browser player for ${playedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`) });
|
||||
await player.waitFor();
|
||||
await player.getByRole("button", { name: "Exit game" }).click();
|
||||
await player.waitFor({ state: "hidden" });
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await page.goto(`${baseUrl}/#Home`, { waitUntil: "networkidle" });
|
||||
await page.getByRole("heading", { name: "Recently played" }).waitFor();
|
||||
const recent = page.locator(".home-recent-list article").filter({ hasText: playedTitle }).first();
|
||||
await recent.getByRole("link", { name: `Open details for ${playedTitle}` }).waitFor();
|
||||
const recentPlay = recent.getByRole("button", { name: /^(Play|Resume)$/ });
|
||||
await recentPlay.waitFor();
|
||||
await recentPlay.click();
|
||||
const resumedPlayer = page.getByRole("region", { name: new RegExp(`Browser player for ${playedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`) });
|
||||
await resumedPlayer.waitFor();
|
||||
await resumedPlayer.getByRole("button", { name: "Exit game" }).click();
|
||||
await resumedPlayer.waitFor({ state: "hidden" });
|
||||
await page.goto(`${baseUrl}/#Library`, { waitUntil: "networkidle" });
|
||||
}
|
||||
// Any platform the archive actually holds proves the filter; naming one tied the gate to an
|
||||
// archive that happened to contain Windows installers.
|
||||
const [firstPlatform] = await api("/platforms");
|
||||
assert.ok(firstPlatform, "The candidate archive reports no platforms to filter on.");
|
||||
await page
|
||||
.locator("label.field")
|
||||
.filter({ hasText: "Platform" })
|
||||
.locator("select")
|
||||
.selectOption(firstPlatform.platform);
|
||||
await page.waitForLoadState("networkidle");
|
||||
assert.match(page.url(), /platform=/);
|
||||
await page.locator(".game-card").first().waitFor();
|
||||
assert.ok((await page.locator(".game-card").count()) > 0);
|
||||
assert.equal(
|
||||
await page.locator(".collection-toolbar").evaluate((toolbar) =>
|
||||
[...toolbar.children].every((child) => {
|
||||
const item = child.getBoundingClientRect();
|
||||
const bounds = toolbar.getBoundingClientRect();
|
||||
return item.left >= bounds.left - 1 && item.right <= bounds.right + 1;
|
||||
}),
|
||||
),
|
||||
true,
|
||||
);
|
||||
// A grid that gave a column to only the first few controls squeezed the rest to a sliver: the
|
||||
// operator read "Any s" where the option says "Any status", and a quick filter was cut off.
|
||||
assert.deepEqual(
|
||||
await page.locator(".collection-toolbar select").evaluateAll((controls) =>
|
||||
controls.filter((control) => control.getBoundingClientRect().width < 120)
|
||||
.map((control) => control.closest("label")?.textContent ?? "unnamed"),
|
||||
),
|
||||
[],
|
||||
);
|
||||
assert.equal(await page.getByLabel("Active filters").count(), 1);
|
||||
await page.getByRole("button", { name: "Compact" }).click();
|
||||
assert.ok((await page.locator(".game-browser.density-compact").count()) === 1);
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
assert.ok((await page.locator(".game-browser.density-compact").count()) === 1);
|
||||
await page.getByRole("button", { name: "Reset filters" }).click();
|
||||
assert.equal(await page.getByLabel("Active filters").count(), 0);
|
||||
if (mutateCatalog) {
|
||||
// Organising more than one game at a time is a per-game write behind one control, so the proof
|
||||
// that it worked has to come from the games themselves rather than from the message on screen.
|
||||
const catalog = await api("/games?pageSize=50");
|
||||
const [first, second] = catalog.items ?? catalog;
|
||||
assert.ok(second, "Bulk organising needs at least two games in the archive.");
|
||||
for (const game of [first, second])
|
||||
await page.getByRole("checkbox", { name: `Select ${game.title}` }).check();
|
||||
const bulk = page.getByRole("group", { name: "2 selected games" });
|
||||
await bulk.waitFor();
|
||||
await bulk.getByRole("button", { name: "Favourite", exact: true }).click();
|
||||
await page.getByText(/Favourite applied to 2 games/).waitFor();
|
||||
for (const game of [first, second])
|
||||
assert.equal((await api(`/games/${game.id}/state`)).favorite, true, `${game.title} was not favourited`);
|
||||
|
||||
// A completed change clears the selection, so tagging starts from a fresh one.
|
||||
const tag = `Gate ${Date.now()}`;
|
||||
for (const game of [first, second])
|
||||
await page.getByRole("checkbox", { name: `Select ${game.title}` }).check();
|
||||
await bulk.waitFor();
|
||||
await bulk.getByLabel("Add tag").fill(tag);
|
||||
await bulk.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await page.getByText(new RegExp(`Tag ${tag} applied to 2 games`)).waitFor();
|
||||
for (const game of [first, second]) {
|
||||
const applied = (await api(`/games/${game.id}/tags`)).find((entry) => entry.name === tag);
|
||||
assert.ok(applied, `${game.title} did not receive the tag`);
|
||||
const removed = await fetch(`${baseUrl}/api/v1/games/${game.id}/tags/${applied.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.ok(removed.ok, `the gate could not remove its own tag from ${game.title}`);
|
||||
}
|
||||
|
||||
// Undo what the gate changed, so a rerun starts from the archive it found.
|
||||
for (const game of [first, second])
|
||||
await page.getByRole("checkbox", { name: `Select ${game.title}` }).check();
|
||||
await bulk.waitFor();
|
||||
await bulk.getByRole("button", { name: "Unfavourite" }).click();
|
||||
await page.getByText(/Remove favourite applied to 2 games/).waitFor();
|
||||
for (const game of [first, second])
|
||||
assert.equal((await api(`/games/${game.id}/state`)).favorite, false, `${game.title} stayed favourited`);
|
||||
}
|
||||
await shot(page, "library-desktop");
|
||||
await page.locator(".app-shell > aside").getByRole("link", { name: "Platforms", exact: true }).click();
|
||||
await page.getByText("Your platforms").waitFor();
|
||||
await page.locator(".platform-grid > a").first().click();
|
||||
await page.getByLabel("Active filters").waitFor();
|
||||
await page.getByRole("link", { name: "Windows installers" }).click();
|
||||
await page.getByRole("heading", { name: "Library" }).waitFor();
|
||||
await page.getByRole("link", { name: /Files/ }).click();
|
||||
await page.getByText("Advanced file view").waitFor();
|
||||
const file = page.locator(".file-list > button").first();
|
||||
await file.focus();
|
||||
await file.press("Enter");
|
||||
await page.getByText("Technical details").waitFor();
|
||||
await page.getByText("Technical details").click();
|
||||
assert.ok(await page.getByText("Content identity").isVisible());
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await page.goto(`${baseUrl}/#Wishlist`, { waitUntil: "networkidle" });
|
||||
await page.getByRole("heading", { name: "Wishlist" }).waitFor();
|
||||
const wishTitle = `Synthetic wishlist ${Date.now()}`;
|
||||
await page.getByRole("button", { name: "Add a game" }).click();
|
||||
await page.getByLabel("Game title").fill(wishTitle);
|
||||
await page.getByLabel("Priority").last().selectOption("High");
|
||||
await page.getByLabel("Status").last().selectOption("WaitingForSale");
|
||||
await page.getByLabel("Target price").fill("30");
|
||||
await page.getByRole("spinbutton", { name: "Current price" }).fill("20");
|
||||
await page.getByLabel("Note").fill("Release workflow fixture");
|
||||
await page.getByRole("button", { name: "Add to wishlist" }).click();
|
||||
await page.getByText(wishTitle).waitFor();
|
||||
await page.getByText(/target reached/).waitFor();
|
||||
await page.goto(`${baseUrl}/#Home`, { waitUntil: "networkidle" });
|
||||
await page.getByRole("heading", { name: "Your wishlist" }).waitFor();
|
||||
await page.getByText(wishTitle).waitFor();
|
||||
await page.goto(`${baseUrl}/#Wishlist`, { waitUntil: "networkidle" });
|
||||
await page.getByLabel("Search wishlist").fill(wishTitle);
|
||||
// Every assertion stays inside this run's own card, so an entry left behind by an interrupted
|
||||
// run cannot satisfy or confuse it.
|
||||
const wishCard = page.locator(".wishlist-card", { hasText: wishTitle });
|
||||
await wishCard.getByText(wishTitle).click();
|
||||
await page.getByLabel("Note").fill("Updated release workflow fixture");
|
||||
await page.getByLabel("Status").last().selectOption("Reserved");
|
||||
await page.getByRole("spinbutton", { name: "Current price" }).fill("35");
|
||||
await page.getByRole("button", { name: "Save changes" }).click();
|
||||
await wishCard.locator(".wishlist-note", { hasText: "Updated release workflow fixture" }).waitFor();
|
||||
await wishCard.locator(".wishlist-card-status", { hasText: "Reserved" }).waitFor();
|
||||
await wishCard.getByText(wishTitle).click();
|
||||
await page.getByRole("button", { name: "Remove" }).click();
|
||||
// Removal asks in the application's own focus-trapped dialog, not the browser's, and its button
|
||||
// names the action rather than saying "OK".
|
||||
const removeWish = page.getByRole("alertdialog");
|
||||
await removeWish.waitFor();
|
||||
assert.match(await removeWish.getByRole("heading").innerText(), new RegExp(wishTitle));
|
||||
await removeWish.getByRole("button", { name: "Remove from wishlist" }).click();
|
||||
await removeWish.waitFor({ state: "detached" });
|
||||
await wishCard.waitFor({ state: "detached" });
|
||||
await shot(page, "wishlist-desktop");
|
||||
await page.goto(`${baseUrl}/#Attention`, { waitUntil: "networkidle" });
|
||||
await page.getByRole("heading", { name: "Attention", exact: true }).waitFor();
|
||||
await shot(page, "attention-desktop");
|
||||
const item = page.locator(".attention-list > button").first();
|
||||
if (await item.count()) {
|
||||
await item.click();
|
||||
await page.getByText("Recommended action").waitFor();
|
||||
const defer = page.getByRole("button", { name: "Defer" });
|
||||
if (await defer.count()) {
|
||||
const classify = page.getByRole("button", { name: "Classify as support content" });
|
||||
if (await classify.count()) await classify.click();
|
||||
else await defer.click();
|
||||
await page.getByRole("button", { name: "Undo" }).waitFor();
|
||||
await page.getByRole("button", { name: "Undo" }).click();
|
||||
}
|
||||
}
|
||||
await page.goto(`${baseUrl}/#Activity`, { waitUntil: "networkidle" });
|
||||
await page.getByText("Scan all libraries").waitFor();
|
||||
assert.equal(await page.getByLabel("Scan type").locator("option").count(), 3);
|
||||
await page.getByRole("link", { name: "Snapshots" }).click();
|
||||
await page.getByText("Integrity snapshots").waitFor();
|
||||
await page.getByRole("link", { name: "History", exact: true }).click();
|
||||
await page.getByRole("heading", { name: "Scan history" }).waitFor();
|
||||
await page.getByRole("link", { name: "Exports", exact: true }).click();
|
||||
await page.getByRole("button", { name: /Inventory JSON/ }).click();
|
||||
const inventoryStatus = page.getByRole("status").filter({ hasText: "Inventory JSON" });
|
||||
await inventoryStatus.waitFor({ timeout: 90_000 });
|
||||
assert.doesNotMatch(await inventoryStatus.innerText(), /undefined/);
|
||||
await page.getByRole("button", { name: /Integrity manifest/ }).click();
|
||||
await page.getByRole("status").filter({ hasText: "Integrity manifest" }).waitFor({ timeout: 90_000 });
|
||||
await shot(page, "activity-desktop");
|
||||
await page.goto(`${baseUrl}/#Settings`, { waitUntil: "networkidle" });
|
||||
await page.getByText("Game libraries").waitFor();
|
||||
await page.locator(".library-settings article").first().waitFor();
|
||||
// Settings must show every configured archive, not merely three of them.
|
||||
const libraries = await api("/libraries");
|
||||
assert.equal(await page.locator(".library-settings article").count(), libraries.length);
|
||||
await page.getByRole("button", { name: "Add library" }).click();
|
||||
await page.getByRole("heading", { name: "Add a game library" }).waitFor();
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
await shot(page, "settings-desktop");
|
||||
// Follow the focused Settings destination for playability. It opens on the games that need
|
||||
// attention, and when none do it must say so rather than reading as a dead end.
|
||||
await page.getByRole("navigation", { name: "Settings sections" })
|
||||
.getByRole("link", { name: "Playability & naming", exact: true }).click();
|
||||
await page.getByRole("heading", { name: "Playability & repair" }).waitFor();
|
||||
const blocked = Number(
|
||||
(await page.locator(".repair-summary article").filter({ hasText: "Needs attention" })
|
||||
.locator("b").innerText()).replace(/\D/g, ""),
|
||||
);
|
||||
if (blocked === 0) {
|
||||
assert.equal(await page.getByText("No games match these filters").count(), 0);
|
||||
assert.ok(await page.getByText("Every checked game can start").isVisible());
|
||||
}
|
||||
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
|
||||
await page.locator(".app-shell > aside").getByRole("link", { name: "Home", exact: true }).click();
|
||||
await page.locator(".home-status h1").waitFor();
|
||||
assert.equal(await page.evaluate(() => window.scrollY), 0);
|
||||
assert.equal(errors.length, 0, `Console errors: ${errors.join("; ")}`);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth ===
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await desktop.close();
|
||||
const mobile = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const mp = await mobile.newPage();
|
||||
await authenticate(mp);
|
||||
assert.equal(await mp.locator(".mobile-nav a").count(), 3);
|
||||
assert.equal(await mp.locator(".mobile-nav > a, .mobile-nav > button").count(), 5);
|
||||
for (const destination of ["Home", "Library", "Search Ludarium", "Wishlist", "More"]) {
|
||||
assert.ok(await mp.locator(".mobile-nav").getByRole(destination === "Search Ludarium" || destination === "More" ? "button" : "link", { name: destination }).isVisible());
|
||||
}
|
||||
assert.equal(
|
||||
await mp.locator(".app-shell > aside").evaluate((e) => getComputedStyle(e).display),
|
||||
"none",
|
||||
);
|
||||
await shot(mp, "home-mobile");
|
||||
await mp.locator(".mobile-nav").getByRole("link", { name: "Library" }).click();
|
||||
await mp.getByRole("heading", { name: "Library" }).waitFor();
|
||||
assert.equal(
|
||||
await mp.getByRole("navigation", { name: "Library views" }).evaluate(
|
||||
(tabs) => tabs.scrollWidth <= tabs.clientWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await mp.getByRole("button", { name: "Filters & view" }).click();
|
||||
const mobilePlatform = mp.locator("label.field").filter({ hasText: "Platform" }).locator("select");
|
||||
assert.ok((await mobilePlatform.locator("option").count()) > 1);
|
||||
await mobilePlatform.selectOption({ index: 1 });
|
||||
await mp.waitForLoadState("networkidle");
|
||||
assert.match(mp.url(), /platform=/);
|
||||
await mp.locator(".game-card").first().click();
|
||||
assert.equal(
|
||||
await mp
|
||||
.locator(".drawer")
|
||||
.evaluate((e) => Math.round(e.getBoundingClientRect().width)),
|
||||
390,
|
||||
);
|
||||
await shot(mp, "game-detail-mobile");
|
||||
await mp.getByRole("button", { name: "Close" }).click();
|
||||
await mp.locator(".drawer").waitFor({ state: "hidden" });
|
||||
await mp.locator(".mobile-nav").getByRole("button", { name: "More" }).click();
|
||||
await mp.getByRole("dialog", { name: "More in Ludarium" }).getByRole("link", { name: /Attention/ }).click();
|
||||
await mp.getByRole("heading", { name: "Attention", exact: true }).waitFor();
|
||||
assert.match(mp.url(), /view=decisions/);
|
||||
await mp.getByRole("button", { name: "Filters", exact: true }).click();
|
||||
assert.equal(await mp.getByLabel("Filter attention items").count(), 1);
|
||||
await mp.getByPlaceholder("Cause, path or action").fill("unlikely-filter-value");
|
||||
await mp.getByText("No matching items").waitFor();
|
||||
await shot(mp, "attention-mobile");
|
||||
await mp.locator(".mobile-nav").getByRole("button", { name: "More" }).click();
|
||||
await mp.getByRole("dialog", { name: "More in Ludarium" }).getByRole("link", { name: /Settings/ }).click();
|
||||
await mp.getByRole("heading", { name: "Settings" }).waitFor();
|
||||
const mobileSettingsSelect = mp.getByLabel("Settings section", { exact: true });
|
||||
await mobileSettingsSelect.waitFor({ state: "visible" });
|
||||
assert.ok(await mobileSettingsSelect.isVisible());
|
||||
assert.equal(await mobileSettingsSelect.locator('option[value="repair"]').count(), 1);
|
||||
await mobileSettingsSelect.selectOption("repair");
|
||||
await mp.getByRole("heading", { name: "Playability & repair" }).waitFor();
|
||||
assert.equal(
|
||||
await mp.getByRole("navigation", { name: "Settings sections" }).isVisible(),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
await mp.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth ===
|
||||
document.documentElement.clientWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await mobile.close();
|
||||
console.log(
|
||||
"UX workflow gate passed: Home, global search, Library, Wishlist CRUD, Attention, Activity, Settings and mobile flows.",
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#09101d" />
|
||||
<meta name="description" content="Ludarium — local-first game archive intelligence for Unraid" />
|
||||
<title>Ludarium — Game Archive Intelligence</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "ludarium-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:a11y": "node e2e/accessibility.mjs",
|
||||
"test:e2e:atlas": "node e2e/archive-atlas.mjs",
|
||||
"test:e2e": "node e2e/workflows.mjs",
|
||||
"test:e2e:vault": "node e2e/game-data-vault.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.12.1",
|
||||
"@testing-library/jest-dom": "7.0.0",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.4",
|
||||
"happy-dom": "20.11.1",
|
||||
"playwright": "1.62.1",
|
||||
"typescript": "6.0.2",
|
||||
"vite": "8.2.1",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
"@fontsource-variable/manrope": "5.3.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||
"lightningcss-linux-x64-musl": "1.33.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Living Archive design assets
|
||||
|
||||
These assets are local design inputs for the `design/higgsfield-living-archive` experiment. They are not fetched or generated at runtime.
|
||||
|
||||
| File | Generator | Model | Generation job | Purpose |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `living-archive-core.webp` | Higgsfield | Z Image | `deabe3ac-65a5-4c0b-9054-82c3abb1e79c` | Home archive hero and restrained detail ambience |
|
||||
| `living-archive-shelves.webp` | Higgsfield | Z Image | `217a52f9-d261-4a88-8f68-79af5804a516` | Library header atmosphere |
|
||||
| `living-archive-veo-loop.mp4` | Google Flow | Veo 3.1 Lite | Project `a7e8bde1-04a2-4c73-a793-c77932df3db8`, media `f9ef785a-e23a-47b7-890a-2cb3122ef323` | Stable Home hero atmosphere |
|
||||
|
||||
Both image outputs were generated on 2026-08-23 at 2048×1152, inspected manually, resized to 1600 px wide and encoded as WebP with FFmpeg. Prompts explicitly excluded people, text, logos, recognizable consoles, copyrighted characters and game screenshots. Raw PNG experiments and unused concepts are not shipped.
|
||||
|
||||
The Google Flow output was generated on 2026-08-23 from `living-archive-core.webp` using 10 free daily Flow credits. Its prompt locked all camera movement and limited animation to local light, reflection and dust. The eight-second 1280×720 source was inspected as a one-frame-per-second contact sheet. FFmpeg removed the generated audio and concatenated the forward and reversed video into a seamless sixteen-second H.264 loop. The original Flow download is not shipped.
|
||||
|
||||
The imagery is decorative. It must not communicate operational state on its own, and the interface remains complete when images are unavailable or motion is reduced.
|
||||
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<title>Ludarium Nintendo 64 player</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; background: #07090d; font-family: Inter, system-ui, sans-serif; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #07090d; }
|
||||
body { display: grid; place-items: center; }
|
||||
#canvas { width: min(100vw, calc(100vh * 4 / 3)); height: min(100vh, calc(100vw * 3 / 4)); outline: none; image-rendering: auto; }
|
||||
#status { position: fixed; inset: 0; display: grid; place-content: center; gap: .65rem; padding: 2rem; text-align: center; background: radial-gradient(circle at 50% 35%, #17243a, #07090d 58%); z-index: 3; }
|
||||
#status b { color: #f4f7fb; font-size: 1.05rem; }
|
||||
#status span { color: #a8b3c4; max-width: 38rem; }
|
||||
#status.error b { color: #ffb4ad; }
|
||||
#controls { position: fixed; top: .75rem; right: .75rem; display: flex; gap: .45rem; z-index: 2; opacity: .18; transition: opacity 160ms ease; }
|
||||
#controls:hover, #controls:focus-within { opacity: 1; }
|
||||
button { border: 1px solid #526175; border-radius: .55rem; padding: .45rem .7rem; color: #edf3fa; background: #111927dd; cursor: pointer; }
|
||||
button:disabled { opacity: .45; cursor: default; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas" width="640" height="480" tabindex="0" aria-label="Ludarium Nintendo 64 player"></canvas>
|
||||
<nav id="controls" aria-label="Player controls">
|
||||
<button id="save-state" type="button" disabled>Save state</button>
|
||||
<button id="load-state" type="button" disabled>Load state</button>
|
||||
<button id="fullscreen" type="button">Full screen</button>
|
||||
</nav>
|
||||
<div id="status" role="status"><b>Preparing the isolated Nintendo 64 player…</b><span>The ROM is read from its read-only library only after this explicit play action.</span></div>
|
||||
<script src="/n64-player.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,177 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
const MAX_ROM_BYTES = 512 * 1024 * 1024;
|
||||
const MAX_STATE_BYTES = 64 * 1024 * 1024;
|
||||
const AUDIO_BUFFER_SIZE = 1024;
|
||||
const status = document.getElementById("status");
|
||||
const canvas = document.getElementById("canvas");
|
||||
const saveButton = document.getElementById("save-state");
|
||||
const loadButton = document.getElementById("load-state");
|
||||
const fullscreenButton = document.getElementById("fullscreen");
|
||||
const query = new URLSearchParams(location.search);
|
||||
const fragment = new URLSearchParams(location.hash.slice(1));
|
||||
const session = query.get("session");
|
||||
const core = query.get("core");
|
||||
const token = fragment.get("token");
|
||||
history.replaceState(null, "", location.pathname + location.search);
|
||||
|
||||
let restoredState = null;
|
||||
let started = false;
|
||||
let audio = null;
|
||||
|
||||
const setDetail = message => {
|
||||
const detail = status?.querySelector("span");
|
||||
if (detail) detail.textContent = message;
|
||||
};
|
||||
const fail = message => {
|
||||
if (!status) return;
|
||||
status.classList.add("error");
|
||||
status.replaceChildren();
|
||||
const title = document.createElement("b");
|
||||
const detail = document.createElement("span");
|
||||
title.textContent = "The Nintendo 64 game could not start.";
|
||||
detail.textContent = String(message);
|
||||
status.append(title, detail);
|
||||
};
|
||||
const scopedUrl = suffix => `/api/v1/play-sessions/${session}/${suffix}?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const readBytes = async (url, maximum, optional = false) => {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (optional && (response.status === 204 || response.status === 404)) {
|
||||
await response.arrayBuffer();
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) throw new Error(`The scoped content request returned HTTP ${response.status}.`);
|
||||
const declared = Number(response.headers.get("content-length") || 0);
|
||||
if (declared > maximum) throw new Error("The scoped content exceeds the player limit.");
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (!bytes.byteLength || bytes.byteLength > maximum) throw new Error("The scoped content is empty or exceeds the player limit.");
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const preserveState = async bytes => {
|
||||
if (!bytes?.byteLength || bytes.byteLength > MAX_STATE_BYTES) throw new Error("The savestate is empty or oversized.");
|
||||
const response = await fetch(scopedUrl("data/SaveState"), {
|
||||
method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body: bytes
|
||||
});
|
||||
if (!response.ok) throw new Error(`Ludarium could not preserve the savestate (HTTP ${response.status}).`);
|
||||
restoredState = new Uint8Array(bytes);
|
||||
loadButton.disabled = false;
|
||||
document.body.dataset.stateSaved = "true";
|
||||
};
|
||||
|
||||
const writeConfig = () => {
|
||||
const joy = [12, 13, 14, 15, 0, 2, 9, 4, 6, 5, 11, -1, -1, -1, -1];
|
||||
const keyboard = ["b", "n", "y", "h", "Enter", "i", "k", "j", "l", "a", "q", "e", "s", "d", "`", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
|
||||
const flags = [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0];
|
||||
window.FS.writeFile("config.txt", [...joy, ...keyboard, ...flags].join("\r\n") + "\r\n");
|
||||
window.FS.writeFile("cheat.txt", "");
|
||||
};
|
||||
|
||||
const initializeAudio = module => {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContext) return null;
|
||||
const context = new AudioContext({ latencyHint: "interactive", sampleRate: 44100 });
|
||||
const gain = context.createGain();
|
||||
gain.gain.value = 0.5;
|
||||
gain.connect(context.destination);
|
||||
const source = context.createScriptProcessor(AUDIO_BUFFER_SIZE, 2, 2);
|
||||
const ring = new Int16Array(module.HEAP16.buffer, module._neilGetSoundBufferResampledAddress(), 64000);
|
||||
let read = 0;
|
||||
source.onaudioprocess = event => {
|
||||
const left = event.outputBuffer.getChannelData(0);
|
||||
const right = event.outputBuffer.getChannelData(1);
|
||||
const write = module._neilGetAudioWritePosition();
|
||||
for (let index = 0; index < AUDIO_BUFFER_SIZE; index++) {
|
||||
if (read !== write) {
|
||||
left[index] = ring[read] / 32768;
|
||||
right[index] = ring[read + 1] / 32768;
|
||||
read = (read + 2) % 64000;
|
||||
} else left[index] = right[index] = 0;
|
||||
}
|
||||
module._neil_set_buffer_remaining?.((write - read + 64000) % 64000);
|
||||
};
|
||||
source.connect(gain);
|
||||
const resume = () => context.resume().catch(() => {});
|
||||
window.addEventListener("pointerdown", resume, { passive: true });
|
||||
window.addEventListener("keydown", resume, { passive: true });
|
||||
return { context, source, gain };
|
||||
};
|
||||
|
||||
const applyState = () => {
|
||||
if (!started || !restoredState) return;
|
||||
window.FS.writeFile("/savestate.gz", restoredState);
|
||||
window.Module._neil_unserialize();
|
||||
document.body.dataset.restoredState = "true";
|
||||
};
|
||||
|
||||
const markStarted = () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
document.body.dataset.started = "true";
|
||||
saveButton.disabled = false;
|
||||
loadButton.disabled = !restoredState;
|
||||
status?.remove();
|
||||
canvas.focus();
|
||||
if (restoredState) setTimeout(applyState, 250);
|
||||
};
|
||||
|
||||
if (!session || !/^[0-9a-f-]{36}$/i.test(session) || core !== "n64wasm" || !token) {
|
||||
fail("This play capability is incomplete or expired. Return to Ludarium and start a new session.");
|
||||
return;
|
||||
}
|
||||
|
||||
const start = async () => {
|
||||
setDetail("Loading the verified runtime, ROM and compatible Ludarium savestate…");
|
||||
const [rom, assets, state] = await Promise.all([
|
||||
readBytes(scopedUrl("content"), MAX_ROM_BYTES),
|
||||
readBytes("/n64wasm/assets.zip", 4 * 1024 * 1024),
|
||||
readBytes(scopedUrl("data/SaveState"), MAX_STATE_BYTES, true)
|
||||
]);
|
||||
restoredState = state;
|
||||
|
||||
window.myApp = {
|
||||
rivetsData: { inputController: { updateMobileControls() {} }, useZasCMobile: false },
|
||||
localCallback() {}, fullscreen() { canvas.requestFullscreen?.(); },
|
||||
SaveStateEvent() {
|
||||
try { preserveState(window.FS.readFile("/savestate.gz")).catch(error => fail(error.message)); }
|
||||
catch (error) { fail(error instanceof Error ? error.message : "The savestate could not be read."); }
|
||||
},
|
||||
ExportEepEvent() {}, ExportSraEvent() {}, ExportFlaEvent() {},
|
||||
saveCloud() { window.Module._neil_serialize(); }, loadCloud: applyState
|
||||
};
|
||||
window.Module = {
|
||||
noInitialRun: true,
|
||||
canvas,
|
||||
locateFile: path => `/n64wasm/${path}`,
|
||||
print(text) {
|
||||
console.log(text);
|
||||
if (String(text).includes("Starting R4300 emulator: Cached Interpreter")) markStarted();
|
||||
},
|
||||
printErr(text) { console.warn(text); },
|
||||
onAbort(reason) { fail(`The N64Wasm runtime aborted: ${reason || "unknown error"}.`); },
|
||||
onRuntimeInitialized() {
|
||||
try {
|
||||
window.FS.writeFile("assets.zip", assets);
|
||||
window.FS.writeFile("custom.v64", rom);
|
||||
writeConfig();
|
||||
audio = initializeAudio(window.Module);
|
||||
window.Module.callMain(["custom.v64"]);
|
||||
window.LudariumN64 = { module: window.Module, fs: window.FS, audio, romBytes: rom.byteLength };
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : "The N64Wasm runtime could not initialize.");
|
||||
}
|
||||
}
|
||||
};
|
||||
const loader = document.createElement("script");
|
||||
loader.src = "/n64wasm/n64wasm.js";
|
||||
loader.onerror = () => fail("The bundled N64Wasm runtime is unavailable.");
|
||||
document.body.appendChild(loader);
|
||||
setTimeout(() => { if (!started) fail("The N64 core did not start before the 90 second deadline."); }, 90_000);
|
||||
};
|
||||
|
||||
saveButton.addEventListener("click", () => window.myApp?.saveCloud());
|
||||
loadButton.addEventListener("click", applyState);
|
||||
fullscreenButton.addEventListener("click", () => canvas.requestFullscreen?.());
|
||||
start().catch(error => fail(error instanceof Error ? error.message : "The Nintendo 64 runtime could not initialize."));
|
||||
})();
|
||||
|
After Width: | Height: | Size: 720 KiB |
@@ -0,0 +1,35 @@
|
||||
# Platform hardware imagery
|
||||
|
||||
The platform overview uses locally bundled hardware photographs and interface illustrations so the interface remains fully self-hosted and does not leak browsing data to third-party image hosts.
|
||||
|
||||
Unless noted otherwise, the photographs are by Evan-Amos and were released into the public domain on Wikimedia Commons. The source pages remain the authoritative license records.
|
||||
|
||||
| Asset | Subject | Source | License |
|
||||
|---|---|---|---|
|
||||
| `switch.jpg` | Nintendo Switch | https://commons.wikimedia.org/wiki/File:Nintendo-Switch-Console-Docked-wJoyConRB.jpg | Public domain |
|
||||
| `n64.png` | Nintendo 64 | https://commons.wikimedia.org/wiki/File:N64-Console-Set.png | Public domain |
|
||||
| `psx.png` | PlayStation | https://commons.wikimedia.org/wiki/File:PSX-Console-wController.png | Public domain |
|
||||
| `ps2.png` | PlayStation 2 | https://commons.wikimedia.org/wiki/File:PS2-Versions.png | Public domain |
|
||||
| `ps5.png` | PlayStation 5 | https://commons.wikimedia.org/wiki/File:PlayStation_5_and_DualSense_with_transparent_background.png | CC BY-SA 4.0; original by Osh33m, retouched by Soberian |
|
||||
| `psp.png` | PlayStation Portable | https://commons.wikimedia.org/wiki/File:Sony-PSP-1000-Body.png | Public domain |
|
||||
| `psvita.png` | PlayStation Vita | https://commons.wikimedia.org/wiki/File:PlayStation-Vita-1101-FL.png | Public domain |
|
||||
| `nes.png` | Nintendo Entertainment System | https://commons.wikimedia.org/wiki/File:NES-Console-Set.png | Public domain |
|
||||
| `snes.jpg` | Super Nintendo | https://commons.wikimedia.org/wiki/File:SNES-Mod1-Console-Set.jpg | Public domain |
|
||||
| `gb.jpg` | Game Boy | https://commons.wikimedia.org/wiki/File:Game-Boy-Original.jpg | Public domain |
|
||||
| `gbc.jpg` | Game Boy Color | https://commons.wikimedia.org/wiki/File:Nintendo-Game-Boy-Color-FL.jpg | Public domain |
|
||||
| `gba.png` | Game Boy Advance | https://commons.wikimedia.org/wiki/File:Nintendo-Game-Boy-Advance-Milky-Blue-FL.png | Public domain |
|
||||
| `nds.png` | Nintendo DS Lite | https://commons.wikimedia.org/wiki/File:Nintendo-DS-Lite-w-stylus.png | Public domain |
|
||||
| `3ds.png` | Nintendo 3DS | https://commons.wikimedia.org/wiki/File:Nintendo-3DS-AquaOpen.png | Public domain |
|
||||
| `wii.png` | Nintendo Wii | https://commons.wikimedia.org/wiki/File:Wii_console.png | Public domain |
|
||||
| `windows.jpg` | Gaming PC | https://commons.wikimedia.org/wiki/File:Gaming_pc.jpg | CC0 1.0 |
|
||||
|
||||
Images are resized for web delivery without changing their subject matter.
|
||||
# Transparent hardware cutouts
|
||||
|
||||
`switch-cutout.png`, `gb-cutout-v2.png`, `gbc-cutout.png` and `snes-cutout-v2.png` are non-destructive background-extraction derivatives of the adjacent source photographs. They were produced with the built-in OpenAI image editing tool on 2026-08-23. Only the studio background was removed; the original source files remain unchanged for provenance and rollback.
|
||||
|
||||
## Generated archive portraits
|
||||
|
||||
`ps3-cutout.png`, `ps4-cutout.png`, `gamecube-cutout.png`, `wiiu-cutout.png`, `xbox-cutout.png`, `xbox360-cutout.png`, `xboxone-cutout.png`, and `xboxseries-cutout.png` are newly generated, unbranded hardware portraits made with the built-in OpenAI image tool on 2026-08-23. Each prompt requested a platform-recognizable, three-quarter product illustration with restrained archive lighting, no text or logos, and a genuine transparent background. These local assets replace the misleading Gaming PC fallback for supported console identifiers.
|
||||
|
||||
`generic-platform.svg` is the intentionally neutral fallback for a future or unknown platform; it never implies that unknown hardware is a Windows PC.
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 401 KiB |