Files
Ludarium-Public/src/Ludarium.Infrastructure/GameArtworkStore.cs
T
Ludarium release export df869819ce
Public source validation / source (push) Successful in 2m16s
Publish Ludarium source
2026-09-03 02:08:58 +02:00

184 lines
9.1 KiB
C#

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);