diff --git a/Directory.Packages.props b/Directory.Packages.props
index 2707f90c4..2951d87c9 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -27,7 +27,7 @@
-
+
@@ -39,6 +39,8 @@
+
+
@@ -75,7 +77,7 @@
-
+
diff --git a/NexusMods.App.sln b/NexusMods.App.sln
index b4605a168..da34ba818 100644
--- a/NexusMods.App.sln
+++ b/NexusMods.App.sln
@@ -254,12 +254,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Collections", "sr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Collections.Tests", "tests\NexusMods.Collections.Tests\NexusMods.Collections.Tests.csproj", "{8C817874-7A88-450E-B216-851A1B03684C}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Media", "src\Abstractions\NexusMods.Abstractions.Media\NexusMods.Abstractions.Media.csproj", "{5CB6D02C-07D0-4C0D-BF5C-4E2E958A0612}"
-EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Games.Larian", "src\Games\NexusMods.Games.Larian\NexusMods.Games.Larian.csproj", "{2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Games.Larian.Tests", "tests\Games\NexusMods.Games.Larian.Tests\NexusMods.Games.Larian.Tests.csproj", "{425F7A13-99A2-4231-B0C1-C56EB819C174}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Media", "src\NexusMods.Media\NexusMods.Media.csproj", "{CEC177AB-4FF0-4F8A-81B8-1E756D892416}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Resources", "src\Abstractions\NexusMods.Abstractions.Resources\NexusMods.Abstractions.Resources.csproj", "{8744F914-BF51-4276-AFDA-9CBD750B8187}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Resources.DB", "src\Abstractions\NexusMods.Abstractions.Resources.DB\NexusMods.Abstractions.Resources.DB.csproj", "{856B58BA-8B98-42C5-9129-273A679697D0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Resources.Caching", "src\Abstractions\NexusMods.Abstractions.Resources.Caching\NexusMods.Abstractions.Resources.Caching.csproj", "{BE8C17C4-E3B0-4D07-8CD0-0D15C3CCA9D5}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Resources.IO", "src\Abstractions\NexusMods.Abstractions.Resources.IO\NexusMods.Abstractions.Resources.IO.csproj", "{D3BA5B5A-668A-443B-872C-3116CBB0BC0D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Resources.Resilience", "src\Abstractions\NexusMods.Abstractions.Resources.Resilience\NexusMods.Abstractions.Resources.Resilience.csproj", "{04219A58-C99C-4C3B-A477-5E4B29D1F275}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -662,10 +672,6 @@ Global
{8C817874-7A88-450E-B216-851A1B03684C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8C817874-7A88-450E-B216-851A1B03684C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8C817874-7A88-450E-B216-851A1B03684C}.Release|Any CPU.Build.0 = Release|Any CPU
- {5CB6D02C-07D0-4C0D-BF5C-4E2E958A0612}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {5CB6D02C-07D0-4C0D-BF5C-4E2E958A0612}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {5CB6D02C-07D0-4C0D-BF5C-4E2E958A0612}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {5CB6D02C-07D0-4C0D-BF5C-4E2E958A0612}.Release|Any CPU.Build.0 = Release|Any CPU
{2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -674,6 +680,30 @@ Global
{425F7A13-99A2-4231-B0C1-C56EB819C174}.Debug|Any CPU.Build.0 = Debug|Any CPU
{425F7A13-99A2-4231-B0C1-C56EB819C174}.Release|Any CPU.ActiveCfg = Release|Any CPU
{425F7A13-99A2-4231-B0C1-C56EB819C174}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CEC177AB-4FF0-4F8A-81B8-1E756D892416}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CEC177AB-4FF0-4F8A-81B8-1E756D892416}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CEC177AB-4FF0-4F8A-81B8-1E756D892416}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CEC177AB-4FF0-4F8A-81B8-1E756D892416}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8744F914-BF51-4276-AFDA-9CBD750B8187}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8744F914-BF51-4276-AFDA-9CBD750B8187}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8744F914-BF51-4276-AFDA-9CBD750B8187}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8744F914-BF51-4276-AFDA-9CBD750B8187}.Release|Any CPU.Build.0 = Release|Any CPU
+ {856B58BA-8B98-42C5-9129-273A679697D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {856B58BA-8B98-42C5-9129-273A679697D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {856B58BA-8B98-42C5-9129-273A679697D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {856B58BA-8B98-42C5-9129-273A679697D0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BE8C17C4-E3B0-4D07-8CD0-0D15C3CCA9D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BE8C17C4-E3B0-4D07-8CD0-0D15C3CCA9D5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BE8C17C4-E3B0-4D07-8CD0-0D15C3CCA9D5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BE8C17C4-E3B0-4D07-8CD0-0D15C3CCA9D5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D3BA5B5A-668A-443B-872C-3116CBB0BC0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D3BA5B5A-668A-443B-872C-3116CBB0BC0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D3BA5B5A-668A-443B-872C-3116CBB0BC0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D3BA5B5A-668A-443B-872C-3116CBB0BC0D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {04219A58-C99C-4C3B-A477-5E4B29D1F275}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {04219A58-C99C-4C3B-A477-5E4B29D1F275}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {04219A58-C99C-4C3B-A477-5E4B29D1F275}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {04219A58-C99C-4C3B-A477-5E4B29D1F275}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -791,9 +821,14 @@ Global
{BF6EEEA3-9C9C-404E-9B2D-6926EF503384} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C}
{A9FD538A-E101-4AEA-A98E-35DCED950AEE} = {E7BAE287-D505-4D6D-A090-665A64309B2D}
{8C817874-7A88-450E-B216-851A1B03684C} = {52AF9D62-7D5B-4AD0-BA12-86F2AA67428B}
- {5CB6D02C-07D0-4C0D-BF5C-4E2E958A0612} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C}
{2A35EBB5-1CA6-4F5D-8CE8-352146C82C28} = {70D38D24-79AE-4600-8E83-17F3C11BA81F}
{425F7A13-99A2-4231-B0C1-C56EB819C174} = {05B06AC1-7F2B-492F-983E-5BC63CDBF20D}
+ {CEC177AB-4FF0-4F8A-81B8-1E756D892416} = {E7BAE287-D505-4D6D-A090-665A64309B2D}
+ {8744F914-BF51-4276-AFDA-9CBD750B8187} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C}
+ {856B58BA-8B98-42C5-9129-273A679697D0} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C}
+ {BE8C17C4-E3B0-4D07-8CD0-0D15C3CCA9D5} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C}
+ {D3BA5B5A-668A-443B-872C-3116CBB0BC0D} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C}
+ {04219A58-C99C-4C3B-A477-5E4B29D1F275} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {9F9F8352-34DD-42C0-8564-EE9AF34A3501}
diff --git a/src/Abstractions/NexusMods.Abstractions.Media/IImageStore.cs b/src/Abstractions/NexusMods.Abstractions.Media/IImageStore.cs
deleted file mode 100644
index 393a4905c..000000000
--- a/src/Abstractions/NexusMods.Abstractions.Media/IImageStore.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using Avalonia.Media.Imaging;
-using BitFaster.Caching;
-using JetBrains.Annotations;
-using NexusMods.MnemonicDB.Abstractions;
-using OneOf;
-
-namespace NexusMods.Abstractions.Media;
-
-///
-/// Optimized storage for images.
-///
-[PublicAPI]
-public interface IImageStore
-{
- ValueTask PutAsync(Bitmap bitmap);
-
- [MustDisposeResource] Lifetime? Get(OneOf input);
-
- StoredImage.New CreateStoredImage(ITransaction transaction, Bitmap bitmap);
-}
diff --git a/src/Abstractions/NexusMods.Abstractions.Media/ImageData.cs b/src/Abstractions/NexusMods.Abstractions.Media/ImageData.cs
deleted file mode 100644
index 6f19fcd35..000000000
--- a/src/Abstractions/NexusMods.Abstractions.Media/ImageData.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-namespace NexusMods.Abstractions.Media;
-
-///
-/// Image data.
-///
-public readonly struct ImageData
-{
- ///
- /// Compression type.
- ///
- public readonly ImageDataCompression Compression;
-
- ///
- /// Binary data.
- ///
- public readonly byte[] Data;
-
- ///
- /// Constructor.
- ///
- public ImageData(ImageDataCompression compression, byte[] data)
- {
- Compression = compression;
- Data = data;
- }
-}
-
-///
-/// Compression types.
-///
-public enum ImageDataCompression : byte
-{
- ///
- /// No compression.
- ///
- None = 0,
-}
diff --git a/src/Abstractions/NexusMods.Abstractions.Media/ImageDataAttribute.cs b/src/Abstractions/NexusMods.Abstractions.Media/ImageDataAttribute.cs
deleted file mode 100644
index 3eb07d654..000000000
--- a/src/Abstractions/NexusMods.Abstractions.Media/ImageDataAttribute.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-using System.Diagnostics;
-using NexusMods.MnemonicDB.Abstractions;
-using NexusMods.MnemonicDB.Abstractions.Attributes;
-using NexusMods.MnemonicDB.Abstractions.ElementComparers;
-
-namespace NexusMods.Abstractions.Media;
-
-///
-/// Binary blob containing image data.
-///
-public class ImageDataAttribute(string ns, string name) : BlobAttribute(ns, name)
-{
- ///
- protected override ImageData FromLowLevel(ReadOnlySpan value, ValueTags tags, AttributeResolver resolver)
- {
- Debug.Assert(sizeof(ImageDataCompression) == 1);
- var compression = (ImageDataCompression)value[0];
-
- var data = Decompress(compression, value[1..]);
- return new ImageData(compression, data);
- }
-
- ///
- protected override void WriteValue(ImageData value, TWriter writer)
- {
- Debug.Assert(sizeof(ImageDataCompression) == 1);
- var count = value.Data.Length + sizeof(ImageDataCompression);
-
- var span = writer.GetSpan(sizeHint: count);
- span[0] = (byte)value.Compression;
-
- var bytesWritten = Compress(value.Compression, value.Data, span[1..]);
- writer.Advance(bytesWritten + sizeof(ImageDataCompression));
- }
-
- private static int Compress(ImageDataCompression compression, byte[] data, Span outputSpan)
- {
- switch (compression)
- {
- case ImageDataCompression.None:
- data.CopyTo(outputSpan);
- return data.Length;
- default:
- throw new ArgumentOutOfRangeException();
- }
- }
-
- private static byte[] Decompress(ImageDataCompression compression, ReadOnlySpan data)
- {
- switch (compression)
- {
- case ImageDataCompression.None:
- return data.ToArray();
- default:
- throw new ArgumentOutOfRangeException();
- }
- }
-}
diff --git a/src/Abstractions/NexusMods.Abstractions.Media/ImageMetadata.cs b/src/Abstractions/NexusMods.Abstractions.Media/ImageMetadata.cs
deleted file mode 100644
index d5a15803f..000000000
--- a/src/Abstractions/NexusMods.Abstractions.Media/ImageMetadata.cs
+++ /dev/null
@@ -1,107 +0,0 @@
-using System.Diagnostics;
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices;
-using Avalonia;
-using Avalonia.Platform;
-using Avalonia.Skia;
-using SkiaSharp;
-
-namespace NexusMods.Abstractions.Media;
-
-///
-/// Metadata of an image.
-///
-[StructLayout(LayoutKind.Sequential)]
-public readonly struct ImageMetadata
-{
- ///
- /// Width.
- ///
- public readonly uint ImageWidth;
-
- ///
- /// Height.
- ///
- public readonly uint ImageHeight;
-
- ///
- /// Color type.
- ///
- public readonly SKColorType SkColorType;
-
- ///
- /// Alpha format.
- ///
- public readonly AlphaFormat AlphaFormat;
-
- ///
- /// DPI.
- ///
- public readonly uint Dpi;
-
- ///
- /// Pixel size.
- ///
- public PixelSize PixelSize => new((int)ImageWidth, (int)ImageHeight);
-
- ///
- /// Pixel format.
- ///
- public PixelFormat PixelFormat => SkColorType.ToPixelFormat();
-
- // NOTE(erri120): Going from bits to bytes requires dividing by 8, aka bit shift by 3
-
- ///
- /// Stride is the number of bytes from one row pixels in memory to the next row.
- ///
- public int Stride => ((int)ImageWidth * PixelFormat.BitsPerPixel) >> 3;
-
- ///
- /// Total length of the raw data.
- ///
- public ulong DataLength => (ImageWidth * ImageHeight * (uint)PixelFormat.BitsPerPixel) >> 3;
-
- ///
- /// Constructor.
- ///
- public ImageMetadata(uint imageWidth, uint imageHeight, SKColorType skColorType, AlphaFormat alphaFormat, uint dpi)
- {
- ImageWidth = imageWidth;
- ImageHeight = imageHeight;
- SkColorType = skColorType;
- AlphaFormat = alphaFormat;
- Dpi = dpi;
- }
-
- ///
- /// Reads the binary data as metadata.
- ///
- public static ImageMetadata Read(ReadOnlySpan bytes)
- {
- Debug.Assert(bytes.Length == Marshal.SizeOf());
-
- unsafe
- {
- fixed (byte* b = bytes)
- {
- return Unsafe.Read(b);
- }
- }
- }
-
- ///
- /// Writes the metadata as binary data.
- ///
- public void Write(Span bytes)
- {
- Debug.Assert(bytes.Length == Marshal.SizeOf());
-
- unsafe
- {
- fixed (void* b = bytes)
- {
- Unsafe.Write(b, this);
- }
- }
- }
-}
diff --git a/src/Abstractions/NexusMods.Abstractions.Media/ImageMetadataAttribute.cs b/src/Abstractions/NexusMods.Abstractions.Media/ImageMetadataAttribute.cs
deleted file mode 100644
index 7287ea5b5..000000000
--- a/src/Abstractions/NexusMods.Abstractions.Media/ImageMetadataAttribute.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-using System.Runtime.InteropServices;
-using NexusMods.MnemonicDB.Abstractions;
-using NexusMods.MnemonicDB.Abstractions.Attributes;
-using NexusMods.MnemonicDB.Abstractions.ElementComparers;
-
-namespace NexusMods.Abstractions.Media;
-
-///
-/// Binary blob representation of .
-///
-public class ImageMetadataAttribute(string ns, string name) : BlobAttribute(ns, name)
-{
- private static readonly int ImageMetadataSize = Marshal.SizeOf();
-
- ///
- protected override ImageMetadata FromLowLevel(ReadOnlySpan value, ValueTags tags, AttributeResolver resolver)
- {
- return ImageMetadata.Read(value);
- }
-
- ///
- protected override void WriteValue(ImageMetadata value, TWriter writer)
- {
- var span = writer.GetSpan(sizeHint: ImageMetadataSize);
- value.Write(span);
- writer.Advance(ImageMetadataSize);
- }
-}
diff --git a/src/Abstractions/NexusMods.Abstractions.Media/ServiceExtensions.cs b/src/Abstractions/NexusMods.Abstractions.Media/ServiceExtensions.cs
deleted file mode 100644
index fc8be43ac..000000000
--- a/src/Abstractions/NexusMods.Abstractions.Media/ServiceExtensions.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using Microsoft.Extensions.DependencyInjection;
-
-namespace NexusMods.Abstractions.Media;
-
-///
-/// Extension methods.
-///
-public static class ServiceExtensions
-{
- ///
- /// Adds media.
- ///
- public static IServiceCollection AddMedia(this IServiceCollection serviceCollection)
- {
- return serviceCollection.AddStoredImageModel();
- }
-}
diff --git a/src/Abstractions/NexusMods.Abstractions.Media/StoredImage.cs b/src/Abstractions/NexusMods.Abstractions.Media/StoredImage.cs
deleted file mode 100644
index 3891c3db8..000000000
--- a/src/Abstractions/NexusMods.Abstractions.Media/StoredImage.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using JetBrains.Annotations;
-using NexusMods.MnemonicDB.Abstractions.Models;
-
-namespace NexusMods.Abstractions.Media;
-
-///
-/// Represent an image.
-///
-[UsedImplicitly]
-public partial class StoredImage : IModelDefinition
-{
- private const string Namespace = "NexusMods.ImageStore.StoredImage";
-
- ///
- /// Image data.
- ///
- public static readonly ImageDataAttribute ImageData = new(Namespace, nameof(ImageData)) { NoHistory = true };
-
- ///
- /// Image metadata.
- ///
- public static readonly ImageMetadataAttribute Metadata = new(Namespace, nameof(Metadata));
-}
diff --git a/src/Abstractions/NexusMods.Abstractions.MnemonicDB.Attributes/BytesAttribute.cs b/src/Abstractions/NexusMods.Abstractions.MnemonicDB.Attributes/BytesAttribute.cs
new file mode 100644
index 000000000..922720486
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.MnemonicDB.Attributes/BytesAttribute.cs
@@ -0,0 +1,23 @@
+using NexusMods.MnemonicDB.Abstractions;
+using NexusMods.MnemonicDB.Abstractions.Attributes;
+using NexusMods.MnemonicDB.Abstractions.ElementComparers;
+
+namespace NexusMods.Abstractions.MnemonicDB.Attributes;
+
+///
+/// Bytes.
+///
+public class BytesAttribute(string ns, string name) : BlobAttribute(ns, name)
+{
+ ///
+ protected override byte[] FromLowLevel(ReadOnlySpan value, ValueTags tags, AttributeResolver resolver) => value.ToArray();
+
+ ///
+ protected override void WriteValue(byte[] value, TWriter writer)
+ {
+ var span = writer.GetSpan(sizeHint: value.Length);
+
+ value.CopyTo(span);
+ writer.Advance(value.Length);
+ }
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusMods.Abstractions.NexusModsLibrary.csproj b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusMods.Abstractions.NexusModsLibrary.csproj
index 65669311d..a65ed011a 100644
--- a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusMods.Abstractions.NexusModsLibrary.csproj
+++ b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusMods.Abstractions.NexusModsLibrary.csproj
@@ -9,6 +9,7 @@
+
diff --git a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsModPageMetadata.cs b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsModPageMetadata.cs
index 73f293adb..6d4cd1a75 100644
--- a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsModPageMetadata.cs
+++ b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsModPageMetadata.cs
@@ -1,6 +1,7 @@
using JetBrains.Annotations;
using NexusMods.Abstractions.MnemonicDB.Attributes;
using NexusMods.Abstractions.NexusWebApi.Types;
+using NexusMods.Abstractions.Resources.DB;
using NexusMods.Abstractions.Telemetry;
using NexusMods.MnemonicDB.Abstractions.Attributes;
using NexusMods.MnemonicDB.Abstractions.Models;
@@ -35,6 +36,8 @@ public partial class NexusModsModPageMetadata : IModelDefinition
///
public static readonly UriAttribute FullSizedPictureUri = new(Namespace, nameof(FullSizedPictureUri)) { IsOptional = true };
+ public static readonly ReferenceAttribute ThumbnailResource = new(Namespace, nameof(ThumbnailResource)) { IsOptional = true };
+
///
/// Uri for the thumbnail of the full sized picture.
///
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.Caching/NexusMods.Abstractions.Resources.Caching.csproj b/src/Abstractions/NexusMods.Abstractions.Resources.Caching/NexusMods.Abstractions.Resources.Caching.csproj
new file mode 100644
index 000000000..c3fbd3909
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources.Caching/NexusMods.Abstractions.Resources.Caching.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.Caching/ResourceCache.cs b/src/Abstractions/NexusMods.Abstractions.Resources.Caching/ResourceCache.cs
new file mode 100644
index 000000000..7b9ee6cf3
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources.Caching/ResourceCache.cs
@@ -0,0 +1,79 @@
+using BitFaster.Caching;
+using BitFaster.Caching.Lru;
+using JetBrains.Annotations;
+
+namespace NexusMods.Abstractions.Resources.Caching;
+
+[PublicAPI]
+public sealed class ResourceCache : IResourceLoader
+ where TResourceIdentifier : notnull
+ where TData : notnull
+ where TKey : notnull
+{
+ private readonly Func _keyGenerator;
+ private readonly IResourceLoader _inner;
+ private readonly IAsyncCache _cache;
+
+ ///
+ /// Constructor.
+ ///
+ public ResourceCache(
+ Func keyGenerator,
+ IEqualityComparer keyComparer,
+ ICapacityPartition capacityPartition,
+ IResourceLoader inner)
+ {
+ _keyGenerator = keyGenerator;
+ _inner = inner;
+
+ _cache = new ConcurrentLruBuilder()
+ .WithKeyComparer(keyComparer)
+ .WithCapacity(capacityPartition)
+ .AsAsyncCache()
+ .Build();
+ }
+
+ ///
+ public async ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken)
+ {
+ var key = _keyGenerator(resourceIdentifier);
+ var lifetime = await _cache.GetOrAddAsync(key, static (key, state) => AddAsync(state.Item1, state.Item2, state.Item3), (this, resourceIdentifier, cancellationToken));
+
+ return new Resource
+ {
+ Data = lifetime,
+ };
+ }
+
+ private static async Task AddAsync(
+ ResourceCache self,
+ TResourceIdentifier resourceIdentifier,
+ CancellationToken cancellationToken)
+ {
+ var resource = await self._inner.LoadResourceAsync(resourceIdentifier, cancellationToken);
+ return resource.Data;
+ }
+}
+
+public static partial class ExtensionsMethods
+{
+ public static IResourceLoader UseCache(
+ this IResourceLoader inner,
+ Func keyGenerator,
+ IEqualityComparer keyComparer,
+ ICapacityPartition capacityPartition)
+ where TResourceIdentifier : notnull
+ where TData : notnull
+ where TKey : notnull
+ {
+ return inner.Then(
+ state: (keyGenerator, keyComparer, capacityPartition),
+ factory: static (input, inner) => new ResourceCache(
+ keyGenerator: input.keyGenerator,
+ keyComparer: input.keyComparer,
+ capacityPartition: input.capacityPartition,
+ inner: inner
+ )
+ );
+ }
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.Caching/ScopedResourceCache.cs b/src/Abstractions/NexusMods.Abstractions.Resources.Caching/ScopedResourceCache.cs
new file mode 100644
index 000000000..04a15baae
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources.Caching/ScopedResourceCache.cs
@@ -0,0 +1,80 @@
+using BitFaster.Caching;
+using BitFaster.Caching.Lru;
+using JetBrains.Annotations;
+
+namespace NexusMods.Abstractions.Resources.Caching;
+
+[PublicAPI]
+public sealed class ScopedResourceCache : IResourceLoader>
+ where TResourceIdentifier : notnull
+ where TData : IDisposable
+ where TKey : notnull
+{
+ private readonly Func _keyGenerator;
+ private readonly IResourceLoader _inner;
+ private readonly IScopedAsyncCache _cache;
+
+ ///
+ /// Constructor.
+ ///
+ public ScopedResourceCache(
+ Func keyGenerator,
+ IEqualityComparer keyComparer,
+ ICapacityPartition capacityPartition,
+ IResourceLoader inner)
+ {
+ _keyGenerator = keyGenerator;
+ _inner = inner;
+
+ _cache = new ConcurrentLruBuilder()
+ .WithKeyComparer(keyComparer)
+ .WithCapacity(capacityPartition)
+ .AsAsyncCache()
+ .AsScopedCache()
+ .Build();
+ }
+
+ public async ValueTask>> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken)
+ {
+ var key = _keyGenerator(resourceIdentifier);
+ var lifetime = await _cache.ScopedGetOrAddAsync(key, static (key, state) => AddAsync(state.Item1, state.Item2, state.Item3), (this, resourceIdentifier, cancellationToken));
+
+ return new Resource>
+ {
+ Data = lifetime,
+ };
+ }
+
+ private static async Task> AddAsync(
+ ScopedResourceCache self,
+ TResourceIdentifier resourceIdentifier,
+ CancellationToken cancellationToken)
+ {
+ var resource = await self._inner.LoadResourceAsync(resourceIdentifier, cancellationToken);
+ return new Scoped(resource.Data);
+ }
+}
+
+[PublicAPI]
+public static partial class ExtensionsMethods
+{
+ public static IResourceLoader> UseScopedCache(
+ this IResourceLoader inner,
+ Func keyGenerator,
+ IEqualityComparer keyComparer,
+ ICapacityPartition capacityPartition)
+ where TResourceIdentifier : notnull
+ where TData : IDisposable
+ where TKey : notnull
+ {
+ return inner.Then(
+ state: (keyGenerator, keyComparer, capacityPartition),
+ factory: static (input, inner) => new ScopedResourceCache(
+ keyGenerator: input.keyGenerator,
+ keyComparer: input.keyComparer,
+ capacityPartition: input.capacityPartition,
+ inner: inner
+ )
+ );
+ }
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.DB/IdentifierLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources.DB/IdentifierLoader.cs
new file mode 100644
index 000000000..af42a9795
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources.DB/IdentifierLoader.cs
@@ -0,0 +1,73 @@
+using JetBrains.Annotations;
+using NexusMods.MnemonicDB.Abstractions;
+
+namespace NexusMods.Abstractions.Resources.DB;
+
+[PublicAPI]
+public sealed class IdentifierLoader : IResourceLoader
+ where TResourceIdentifier : notnull
+ where TData : notnull
+ where TLowerLevel : notnull
+{
+ private readonly IConnection _connection;
+ private readonly Attribute _attribute;
+ private readonly AttributeId _attributeId;
+
+ private readonly IResourceLoader, TData> _innerLoader;
+
+ ///
+ /// Constructor.
+ ///
+ public IdentifierLoader(
+ IConnection connection,
+ Attribute attribute,
+ IResourceLoader, TData> innerLoader)
+ {
+ _connection = connection;
+ _innerLoader = innerLoader;
+
+ _attribute = attribute;
+ _attributeId = _connection.AttributeCache.GetAttributeId(attribute.Id);
+ }
+
+ ///
+ public ValueTask> LoadResourceAsync(EntityId entityId, CancellationToken cancellationToken)
+ {
+ var resourceIdentifier = GetIdentifier(entityId);
+ return _innerLoader.LoadResourceAsync((entityId, resourceIdentifier), cancellationToken);
+ }
+
+ private TResourceIdentifier GetIdentifier(EntityId entityId)
+ {
+ var indexSegment = _connection.Db.Get(entityId);
+ foreach (var datom in indexSegment)
+ {
+ if (!datom.A.Equals(_attributeId)) continue;
+ var value = _attribute.ReadValue(datom.ValueSpan, datom.Prefix.ValueTag, _connection.AttributeResolver);
+ return value;
+ }
+
+ throw new KeyNotFoundException($"Unable to find a value in Entity `{entityId}` with attribute `{_attribute}`");
+ }
+}
+
+public static partial class ExtensionsMethods
+{
+ public static IResourceLoader EntityIdToIdentifier(
+ this IResourceLoader, TData> inner,
+ IConnection connection,
+ Attribute attribute)
+ where TResourceIdentifier : notnull
+ where TData : notnull
+ where TLowerLevel : notnull
+ {
+ return inner.Then(
+ state: (connection, attribute),
+ factory: static (input, inner) => new IdentifierLoader(
+ connection: input.connection,
+ attribute: input.attribute,
+ innerLoader: inner
+ )
+ );
+ }
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.Media/NexusMods.Abstractions.Media.csproj b/src/Abstractions/NexusMods.Abstractions.Resources.DB/NexusMods.Abstractions.Resources.DB.csproj
similarity index 61%
rename from src/Abstractions/NexusMods.Abstractions.Media/NexusMods.Abstractions.Media.csproj
rename to src/Abstractions/NexusMods.Abstractions.Resources.DB/NexusMods.Abstractions.Resources.DB.csproj
index 98ae0ab4d..4b857d84c 100644
--- a/src/Abstractions/NexusMods.Abstractions.Media/NexusMods.Abstractions.Media.csproj
+++ b/src/Abstractions/NexusMods.Abstractions.Resources.DB/NexusMods.Abstractions.Resources.DB.csproj
@@ -4,11 +4,13 @@
-
-
+
+
+
+
+
-
-
+
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResource.cs b/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResource.cs
new file mode 100644
index 000000000..85f043385
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResource.cs
@@ -0,0 +1,22 @@
+using JetBrains.Annotations;
+using NexusMods.Abstractions.MnemonicDB.Attributes;
+using NexusMods.MnemonicDB.Abstractions.Models;
+
+namespace NexusMods.Abstractions.Resources.DB;
+
+[PublicAPI]
+public partial class PersistedResource : IModelDefinition
+{
+ private const string Namespace = "NexusMods.Resources.PersistedResource";
+
+ public static readonly BytesAttribute Data = new(Namespace, nameof(Data));
+
+ public static readonly DateTimeAttribute ExpiresAt = new(Namespace, nameof(ExpiresAt));
+
+ public static readonly HashAttribute ResourceIdentifierHash = new(Namespace, nameof(ResourceIdentifierHash));
+
+ public partial struct ReadOnly
+ {
+ public bool IsExpired => DateTime.UtcNow > ExpiresAt;
+ }
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResourceLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResourceLoader.cs
new file mode 100644
index 000000000..8116ae9c2
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResourceLoader.cs
@@ -0,0 +1,171 @@
+using DynamicData.Kernel;
+using JetBrains.Annotations;
+using NexusMods.Hashing.xxHash64;
+using NexusMods.MnemonicDB.Abstractions;
+using NexusMods.MnemonicDB.Abstractions.Attributes;
+
+namespace NexusMods.Abstractions.Resources.DB;
+
+[PublicAPI]
+public sealed class PersistedResourceLoader : IResourceLoader
+ where TResourceIdentifier : notnull
+ where TData : notnull
+{
+ public delegate byte[] DataToBytes(TData data);
+ public delegate TData BytesToData(byte[] bytes);
+ public delegate Hash IdentifierToHash(TResourceIdentifier resourceIdentifier);
+
+ public delegate EntityId IdentifierToEntityId(TResourceIdentifier resourceIdentifier);
+
+ private readonly IConnection _connection;
+ private readonly IResourceLoader _innerLoader;
+ private readonly ReferenceAttribute _referenceAttribute;
+ private readonly DataToBytes _dataToBytes;
+ private readonly BytesToData _bytesToData;
+ private readonly IdentifierToHash _identifierToHash;
+ private readonly IdentifierToEntityId _identifierToEntityId;
+ private readonly AttributeId _referenceAttributeId;
+ private readonly Optional _partitionId;
+
+ public PersistedResourceLoader(
+ IConnection connection,
+ ReferenceAttribute referenceAttribute,
+ IdentifierToHash identifierToHash,
+ DataToBytes dataToBytes,
+ BytesToData bytesToData,
+ IdentifierToEntityId identifierToEntityId,
+ Optional partitionId,
+ IResourceLoader innerLoader)
+ {
+ _connection = connection;
+ _innerLoader = innerLoader;
+
+ _dataToBytes = dataToBytes;
+ _bytesToData = bytesToData;
+
+ _identifierToHash = identifierToHash;
+ _identifierToEntityId = identifierToEntityId;
+
+ _referenceAttribute = referenceAttribute;
+ _referenceAttributeId = _connection.AttributeCache.GetAttributeId(_referenceAttribute.Id);
+ _partitionId = partitionId;
+ }
+
+ ///
+ public ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken)
+ {
+ var entityId = _identifierToEntityId(resourceIdentifier);
+ var tuple = (entityId, resourceIdentifier);
+
+ var resource = LoadResource(tuple);
+ if (resource is not null) return ValueTask.FromResult(resource);
+ return SaveResource(tuple, cancellationToken);
+ }
+
+ private Resource? LoadResource(ValueTuple resourceIdentifier)
+ {
+ var db = _connection.Db;
+ var (entityId, innerResourceIdentifier) = resourceIdentifier;
+
+ var persistedResourceId = Optional.None;
+ var indexSegment = db.Datoms(entityId);
+ foreach (var datom in indexSegment)
+ {
+ if (!datom.A.Equals(_referenceAttributeId)) continue;
+ persistedResourceId = _referenceAttribute.ReadValue(datom.ValueSpan, datom.Prefix.ValueTag, _connection.AttributeResolver);
+ }
+
+ if (!persistedResourceId.HasValue) return null;
+ var persistedResource = PersistedResource.Load(db, persistedResourceId.Value);
+
+ if (!persistedResource.IsValid()) return null;
+ if (persistedResource.IsExpired) return null;
+
+ var hash = _identifierToHash(innerResourceIdentifier);
+ if (!persistedResource.ResourceIdentifierHash.Equals(hash)) return null;
+
+ var bytes = persistedResource.Data;
+ var data = _bytesToData(bytes);
+
+ return new Resource
+ {
+ Data = data,
+ ExpiresAt = persistedResource.ExpiresAt,
+ };
+ }
+
+ private async ValueTask> SaveResource(ValueTuple resourceIdentifier, CancellationToken cancellationToken)
+ {
+ var resource = await _innerLoader.LoadResourceAsync(resourceIdentifier.Item2, cancellationToken);
+ var bytes = _dataToBytes(resource.Data);
+
+ using var tx = _connection.BeginTransaction();
+ var tmpId = _partitionId.HasValue ? tx.TempId(_partitionId.Value) : tx.TempId();
+
+ var persisted = new PersistedResource.New(tx, tmpId)
+ {
+ Data = bytes,
+ ExpiresAt = resource.ExpiresAt,
+ ResourceIdentifierHash = _identifierToHash(resourceIdentifier.Item2),
+ };
+
+ _referenceAttribute.Add(tx, resourceIdentifier.Item1, persisted);
+ await tx.Commit();
+
+ return resource;
+ }
+}
+
+///
+/// Extension methods.
+///
+[PublicAPI]
+public static partial class ExtensionsMethods
+{
+ public static IResourceLoader Persist(
+ this IResourceLoader inner,
+ IConnection connection,
+ ReferenceAttribute referenceAttribute,
+ PersistedResourceLoader.IdentifierToHash identifierToHash,
+ PersistedResourceLoader.IdentifierToEntityId identifierToEntityId,
+ Optional partitionId)
+ where TResourceIdentifier : notnull
+ {
+ return inner.Persist(
+ connection: connection,
+ referenceAttribute: referenceAttribute,
+ identifierToHash: identifierToHash,
+ identifierToEntityId: identifierToEntityId,
+ dataToBytes: static bytes => bytes,
+ bytesToData: static bytes => bytes,
+ partitionId: partitionId
+ );
+ }
+
+ public static IResourceLoader Persist(
+ this IResourceLoader inner,
+ IConnection connection,
+ ReferenceAttribute referenceAttribute,
+ PersistedResourceLoader.IdentifierToHash identifierToHash,
+ PersistedResourceLoader.IdentifierToEntityId identifierToEntityId,
+ PersistedResourceLoader.DataToBytes dataToBytes,
+ PersistedResourceLoader.BytesToData bytesToData,
+ Optional partitionId)
+ where TResourceIdentifier : notnull
+ where TData : notnull
+ {
+ return inner.Then(
+ state: (connection, referenceAttribute, identifierToHash, identifierToEntityId, dataToBytes, bytesToData, partitionId),
+ factory: static (input, inner) => new PersistedResourceLoader(
+ connection: input.connection,
+ referenceAttribute: input.referenceAttribute,
+ identifierToHash: input.identifierToHash,
+ identifierToEntityId: input.identifierToEntityId,
+ dataToBytes: input.dataToBytes,
+ bytesToData: input.bytesToData,
+ partitionId: input.partitionId,
+ innerLoader: inner
+ )
+ );
+ }
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.IO/FileStoreLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources.IO/FileStoreLoader.cs
new file mode 100644
index 000000000..88a078afe
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources.IO/FileStoreLoader.cs
@@ -0,0 +1,34 @@
+using JetBrains.Annotations;
+using NexusMods.Abstractions.IO;
+using NexusMods.Hashing.xxHash64;
+
+namespace NexusMods.Abstractions.Resources.IO;
+
+[PublicAPI]
+public sealed class FileStoreLoader : IResourceLoader
+{
+ private readonly IFileStore _fileStore;
+
+ ///
+ /// Constructor.
+ ///
+ public FileStoreLoader(IFileStore fileStore)
+ {
+ _fileStore = fileStore;
+ }
+
+ ///
+ public async ValueTask> LoadResourceAsync(Hash resourceIdentifier, CancellationToken cancellationToken)
+ {
+ await using var stream = await _fileStore.GetFileStream(resourceIdentifier, cancellationToken);
+
+ var bytes = GC.AllocateUninitializedArray(length: (int)stream.Length);
+ var count = await stream.ReadAsync(bytes, cancellationToken: cancellationToken);
+
+ ArgumentOutOfRangeException.ThrowIfNotEqual(count, bytes.Length);
+ return new Resource
+ {
+ Data = bytes,
+ };
+ }
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.IO/HttpLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources.IO/HttpLoader.cs
new file mode 100644
index 000000000..9d4473b8c
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources.IO/HttpLoader.cs
@@ -0,0 +1,53 @@
+using System.Diagnostics;
+using JetBrains.Annotations;
+
+namespace NexusMods.Abstractions.Resources.IO;
+
+[PublicAPI]
+public sealed class HttpLoader : IResourceLoader
+{
+ private readonly HttpClient _httpClient;
+ private const string SupportedScheme = "https";
+
+ ///
+ /// Constructor.
+ ///
+ public HttpLoader(HttpClient httpClient)
+ {
+ _httpClient = httpClient;
+ }
+
+ ///
+ public async ValueTask> LoadResourceAsync(Uri resourceIdentifier, CancellationToken cancellationToken)
+ {
+ Debug.Assert(resourceIdentifier.Scheme.Equals(SupportedScheme, StringComparison.OrdinalIgnoreCase));
+
+ using var responseMessage = await _httpClient.GetAsync(resourceIdentifier, HttpCompletionOption.ResponseContentRead, cancellationToken);
+ responseMessage.EnsureSuccessStatusCode();
+
+ using var content = responseMessage.Content;
+ var bytes = await content.ReadAsByteArrayAsync(cancellationToken: cancellationToken);
+
+ return new Resource
+ {
+ Data = bytes,
+ ExpiresAt = GetExpiresAt(responseMessage),
+ };
+ }
+
+ private static DateTime GetExpiresAt(HttpResponseMessage responseMessage)
+ {
+ var cacheControl = responseMessage.Headers.CacheControl;
+ if (cacheControl is null) return DateTime.MaxValue;
+
+ var maxAge = cacheControl.MaxAge;
+ if (!maxAge.HasValue) return DateTime.MaxValue;
+
+ var age = responseMessage.Headers.Age;
+
+ var diff = maxAge.Value;
+ if (age.HasValue) diff -= age.Value;
+
+ return DateTime.UtcNow + diff;
+ }
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.IO/NexusMods.Abstractions.Resources.IO.csproj b/src/Abstractions/NexusMods.Abstractions.Resources.IO/NexusMods.Abstractions.Resources.IO.csproj
new file mode 100644
index 000000000..01b5630fd
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources.IO/NexusMods.Abstractions.Resources.IO.csproj
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/Extensions.cs b/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/Extensions.cs
new file mode 100644
index 000000000..ecc0ed8c3
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/Extensions.cs
@@ -0,0 +1,52 @@
+using JetBrains.Annotations;
+using Polly;
+using Polly.Fallback;
+
+namespace NexusMods.Abstractions.Resources.Resilience;
+
+///
+/// Extension methods.
+///
+[PublicAPI]
+public static class Extensions
+{
+ ///
+ /// Adds resilience using Polly.
+ ///
+ public static IResourceLoader AddResilience(
+ this IResourceLoader inner,
+ ResiliencePipeline> resiliencePipeline)
+ where TResourceIdentifier : notnull
+ where TData : notnull
+ {
+ return inner.Then(
+ state: resiliencePipeline,
+ factory: static (input, inner) => new PollyWrapper(
+ resiliencePipeline: input,
+ innerLoader: inner
+ )
+ );
+ }
+
+ ///
+ /// Use a fallback value.
+ ///
+ public static IResourceLoader UseFallbackValue(
+ this IResourceLoader inner,
+ TData fallbackValue)
+ where TResourceIdentifier : notnull
+ where TData : notnull
+ {
+ var resiliencePipeline = new ResiliencePipelineBuilder>()
+ .AddFallback(new FallbackStrategyOptions>
+ {
+ FallbackAction = _ => Outcome.FromResultAsValueTask(new Resource
+ {
+ Data = fallbackValue,
+ }),
+ })
+ .Build();
+
+ return inner.AddResilience(resiliencePipeline);
+ }
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/NexusMods.Abstractions.Resources.Resilience.csproj b/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/NexusMods.Abstractions.Resources.Resilience.csproj
new file mode 100644
index 000000000..5deb95b17
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/NexusMods.Abstractions.Resources.Resilience.csproj
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/PollyWrapper.cs b/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/PollyWrapper.cs
new file mode 100644
index 000000000..4f245a93d
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources.Resilience/PollyWrapper.cs
@@ -0,0 +1,37 @@
+using JetBrains.Annotations;
+using Polly;
+
+namespace NexusMods.Abstractions.Resources.Resilience;
+
+///
+/// Uses a resilience pipeline.
+///
+[PublicAPI]
+public sealed class PollyWrapper : IResourceLoader
+ where TResourceIdentifier : notnull
+ where TData : notnull
+{
+ private readonly ResiliencePipeline> _resiliencePipeline;
+ private readonly IResourceLoader _innerLoader;
+
+ ///
+ /// Constructor.
+ ///
+ public PollyWrapper(
+ ResiliencePipeline> resiliencePipeline,
+ IResourceLoader innerLoader)
+ {
+ _resiliencePipeline = resiliencePipeline;
+ _innerLoader = innerLoader;
+ }
+
+ ///
+ public ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken)
+ {
+ return _resiliencePipeline.ExecuteAsync(callback: static (state, cancellationToken) =>
+ {
+ var (innerLoader, resourceIdentifier) = state;
+ return innerLoader.LoadResourceAsync(resourceIdentifier, cancellationToken);
+ }, state: (_innerLoader, resourceIdentifier), cancellationToken: cancellationToken);
+ }
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources/ANestedResourceLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources/ANestedResourceLoader.cs
new file mode 100644
index 000000000..3a6da3970
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources/ANestedResourceLoader.cs
@@ -0,0 +1,35 @@
+using JetBrains.Annotations;
+
+namespace NexusMods.Abstractions.Resources;
+
+///
+/// Nested resource loader.
+///
+[PublicAPI]
+public abstract class ANestedResourceLoader : IResourceLoader
+ where TResourceIdentifier : notnull
+ where TData : notnull
+ where TInnerData : notnull
+{
+ private readonly IResourceLoader _innerLoader;
+
+ ///
+ /// Constructor.
+ ///
+ protected ANestedResourceLoader(IResourceLoader innerLoader)
+ {
+ _innerLoader = innerLoader;
+ }
+
+ ///
+ public async ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken)
+ {
+ var resource = await _innerLoader.LoadResourceAsync(resourceIdentifier, cancellationToken);
+ return await ProcessResourceAsync(resource, resourceIdentifier, cancellationToken);
+ }
+
+ ///
+ /// Process the resource from the inner loader.
+ ///
+ protected abstract ValueTask> ProcessResourceAsync(Resource resource, TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken);
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources/ChangeIdentifier.cs b/src/Abstractions/NexusMods.Abstractions.Resources/ChangeIdentifier.cs
new file mode 100644
index 000000000..b7a70db77
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources/ChangeIdentifier.cs
@@ -0,0 +1,56 @@
+using JetBrains.Annotations;
+
+namespace NexusMods.Abstractions.Resources;
+
+///
+/// Changes the identifier.
+///
+[PublicAPI]
+public sealed class ChangeIdentifier : IResourceLoader
+ where TData : notnull
+ where TOldResourceIdentifier : notnull
+ where TNewResourceIdentifier : notnull
+{
+ private readonly Func _transform;
+ private readonly IResourceLoader _inner;
+
+ ///
+ /// Constructor.
+ ///
+ public ChangeIdentifier(
+ Func transform,
+ IResourceLoader inner)
+ {
+ _transform = transform;
+ _inner = inner;
+ }
+
+ ///
+ public ValueTask> LoadResourceAsync(TOldResourceIdentifier resourceIdentifier, CancellationToken cancellationToken)
+ {
+ var newIdentifier = _transform(resourceIdentifier);
+ return _inner.LoadResourceAsync(newIdentifier, cancellationToken);
+ }
+}
+
+public static partial class Extensions
+{
+ ///
+ /// Change the identifier.
+ ///
+ public static IResourceLoader ChangeIdentifier(
+ this IResourceLoader inner,
+ Func transform)
+ where TData : notnull
+ where TOldResourceIdentifier : notnull
+ where TNewResourceIdentifier : notnull
+ {
+ return inner.Then(
+ state: transform,
+ factory: static (input, inner) => new ChangeIdentifier(
+ transform: input,
+ inner: inner
+ )
+ );
+ }
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources/Extensions.cs b/src/Abstractions/NexusMods.Abstractions.Resources/Extensions.cs
new file mode 100644
index 000000000..27ae74f76
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources/Extensions.cs
@@ -0,0 +1,61 @@
+using JetBrains.Annotations;
+
+namespace NexusMods.Abstractions.Resources;
+
+///
+/// Extension methods.
+///
+[PublicAPI]
+public static partial class Extensions
+{
+ ///
+ /// Chain loaders.
+ ///
+ public static IResourceLoader Then(
+ this IResourceLoader innerLoader,
+ Func, IResourceLoader> factory)
+ where TResourceIdentifier : notnull
+ where TInnerResourceIdentifier : notnull
+ where TData : notnull
+ where TInnerData : notnull
+ {
+ return factory(innerLoader);
+ }
+
+ ///
+ /// Chain loaders.
+ ///
+ public static IResourceLoader Then(
+ this IResourceLoader innerLoader,
+ TState state,
+ Func, IResourceLoader> factory)
+ where TResourceIdentifier : notnull
+ where TInnerResourceIdentifier : notnull
+ where TData : notnull
+ where TInnerData : notnull
+ {
+ return factory(state, innerLoader);
+ }
+
+ public static IResourceLoader ThenDo(
+ this IResourceLoader innerLoader,
+ TState state,
+ Func, CancellationToken, ValueTask>> func)
+ where TResourceIdentifier : notnull
+ where TData : notnull
+ where TInnerData : notnull
+ {
+ return ResourceLoader.Create,
+ TState,
+ Func, CancellationToken, ValueTask>>
+ >>
+ ((innerLoader, state, func), static async (outerState, resourceIdentifier, cancellationToken) =>
+ {
+ var (innerLoader, innerState, func) = outerState;
+
+ var resource = await innerLoader.LoadResourceAsync(resourceIdentifier, cancellationToken);
+ return await func(innerState, resourceIdentifier, resource, cancellationToken);
+ });
+ }
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources/IResourceLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources/IResourceLoader.cs
new file mode 100644
index 000000000..48b5e9027
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources/IResourceLoader.cs
@@ -0,0 +1,17 @@
+using JetBrains.Annotations;
+
+namespace NexusMods.Abstractions.Resources;
+
+///
+/// Represents a resource loader.
+///
+[PublicAPI]
+public interface IResourceLoader
+ where TResourceIdentifier : notnull
+ where TData : notnull
+{
+ ///
+ /// Loads the resource.
+ ///
+ ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken);
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources/NexusMods.Abstractions.Resources.csproj b/src/Abstractions/NexusMods.Abstractions.Resources/NexusMods.Abstractions.Resources.csproj
new file mode 100644
index 000000000..4300f8663
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources/NexusMods.Abstractions.Resources.csproj
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources/Resource.cs b/src/Abstractions/NexusMods.Abstractions.Resources/Resource.cs
new file mode 100644
index 000000000..e96d17186
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources/Resource.cs
@@ -0,0 +1,32 @@
+using JetBrains.Annotations;
+
+namespace NexusMods.Abstractions.Resources;
+
+///
+/// Represents a resource.
+///
+[PublicAPI]
+public record Resource where TData : notnull
+{
+ ///
+ /// Gets the data of the resource.
+ ///
+ public required TData Data { get; init; }
+
+ ///
+ /// Gets the expiration date.
+ ///
+ public DateTime ExpiresAt { get; init; } = DateTime.MaxValue;
+
+ ///
+ /// Creates a new resource.
+ ///
+ public Resource WithData(TOther data) where TOther : notnull
+ {
+ return new Resource
+ {
+ Data = data,
+ ExpiresAt = ExpiresAt,
+ };
+ }
+}
diff --git a/src/Abstractions/NexusMods.Abstractions.Resources/ResourceLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources/ResourceLoader.cs
new file mode 100644
index 000000000..cfbc4dc7e
--- /dev/null
+++ b/src/Abstractions/NexusMods.Abstractions.Resources/ResourceLoader.cs
@@ -0,0 +1,71 @@
+using JetBrains.Annotations;
+
+namespace NexusMods.Abstractions.Resources;
+
+///
+/// Factory methods.
+///
+[PublicAPI]
+public static class ResourceLoader
+{
+ ///
+ /// Create a resource loader.
+ ///
+ public static IResourceLoader Create(
+ Func>> func)
+ where TResourceIdentifier : notnull
+ where TData : notnull
+ {
+ return new Impl(func);
+ }
+
+ ///
+ /// Create a resource loader.
+ ///
+ public static IResourceLoader Create(
+ TState state,
+ Func>> func)
+ where TResourceIdentifier : notnull
+ where TData : notnull
+ where TState : notnull
+ {
+ return new Impl(func, state);
+ }
+
+ private sealed class Impl : IResourceLoader
+ where TResourceIdentifier : notnull
+ where TData : notnull
+ {
+ private readonly Func>> _func;
+
+ public Impl(Func>> func)
+ {
+ _func = func;
+ }
+
+ public ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken)
+ {
+ return _func(resourceIdentifier, cancellationToken);
+ }
+ }
+
+ private sealed class Impl : IResourceLoader
+ where TResourceIdentifier : notnull
+ where TData : notnull
+ where TState : notnull
+ {
+ private readonly Func>> _func;
+ private readonly TState _state;
+
+ public Impl(Func>> func, TState state)
+ {
+ _func = func;
+ _state = state;
+ }
+
+ public ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken)
+ {
+ return _func(_state, resourceIdentifier, cancellationToken);
+ }
+ }
+}
diff --git a/src/Games/NexusMods.Games.StardewValley/Emitters/DependencyDiagnosticEmitter.cs b/src/Games/NexusMods.Games.StardewValley/Emitters/DependencyDiagnosticEmitter.cs
index bf9e6a749..3128bc8bc 100644
--- a/src/Games/NexusMods.Games.StardewValley/Emitters/DependencyDiagnosticEmitter.cs
+++ b/src/Games/NexusMods.Games.StardewValley/Emitters/DependencyDiagnosticEmitter.cs
@@ -1,5 +1,8 @@
using System.Collections.Immutable;
+using System.Reactive;
using System.Runtime.CompilerServices;
+using System.Text;
+using BitFaster.Caching.Lru;
using DynamicData.Kernel;
using Microsoft.Extensions.Logging;
using NexusMods.Abstractions.Diagnostics;
@@ -8,8 +11,14 @@
using NexusMods.Abstractions.IO;
using NexusMods.Abstractions.Loadouts;
using NexusMods.Abstractions.Loadouts.Extensions;
+using NexusMods.Abstractions.Resources;
+using NexusMods.Abstractions.Resources.Caching;
+using NexusMods.Abstractions.Resources.DB;
+using NexusMods.Abstractions.Resources.IO;
using NexusMods.Games.StardewValley.Models;
using NexusMods.Games.StardewValley.WebAPI;
+using NexusMods.Hashing.xxHash64;
+using NexusMods.MnemonicDB.Abstractions;
using NexusMods.Paths;
using StardewModdingAPI;
using StardewModdingAPI.Toolkit;
@@ -36,6 +45,31 @@ public DependencyDiagnosticEmitter(
_os = os;
}
+ private static IResourceLoader CreatePipeline(IFileStore fileStore)
+ {
+ var pipeline = new FileStoreLoader(fileStore)
+ .ThenDo(Unit.Default, static (_, _, resource, _) =>
+ {
+ var bytes = resource.Data;
+ var json = Encoding.UTF8.GetString(bytes);
+
+ var manifest = Interop.SMAPIJsonHelper.Deserialize(json);
+ ArgumentNullException.ThrowIfNull(manifest);
+
+ return ValueTask.FromResult(resource.WithData(manifest));
+ })
+ .UseCache(
+ keyGenerator: static hash => hash,
+ keyComparer: EqualityComparer.Default,
+ capacityPartition: new FavorWarmPartition(totalCapacity: 100)
+ )
+ .ChangeIdentifier(
+ static mod => mod.Manifest.AsLoadoutFile().Hash
+ );
+
+ return pipeline;
+ }
+
public async IAsyncEnumerable Diagnose(Loadout.ReadOnly loadout, [EnumeratorCancellation] CancellationToken cancellationToken)
{
var gameVersion = new SemanticVersion(loadout.InstallationInstance.Version);
diff --git a/src/Games/NexusMods.Games.StardewValley/NexusMods.Games.StardewValley.csproj b/src/Games/NexusMods.Games.StardewValley/NexusMods.Games.StardewValley.csproj
index e49624073..f17a9acc8 100644
--- a/src/Games/NexusMods.Games.StardewValley/NexusMods.Games.StardewValley.csproj
+++ b/src/Games/NexusMods.Games.StardewValley/NexusMods.Games.StardewValley.csproj
@@ -19,6 +19,10 @@
+
+
+
+
diff --git a/src/NexusMods.App.UI/IImageCache.cs b/src/NexusMods.App.UI/IImageCache.cs
index ab8b29f52..68651232d 100644
--- a/src/NexusMods.App.UI/IImageCache.cs
+++ b/src/NexusMods.App.UI/IImageCache.cs
@@ -9,6 +9,7 @@ namespace NexusMods.App.UI;
/// Represents an image cache.
///
[PublicAPI]
+[Obsolete("To be replaced with resource pipelines")]
public interface IImageCache : IDisposable
{
///
diff --git a/src/NexusMods.App.UI/ImageStore.cs b/src/NexusMods.App.UI/ImageStore.cs
deleted file mode 100644
index b39816410..000000000
--- a/src/NexusMods.App.UI/ImageStore.cs
+++ /dev/null
@@ -1,169 +0,0 @@
-using System.Diagnostics;
-using Avalonia;
-using Avalonia.Media.Imaging;
-using Avalonia.Skia;
-using BitFaster.Caching;
-using JetBrains.Annotations;
-using NexusMods.Abstractions.Media;
-using NexusMods.MnemonicDB.Abstractions;
-using OneOf;
-using Size = NexusMods.Paths.Size;
-
-namespace NexusMods.App.UI;
-
-public sealed class ImageStore : IImageStore, IDisposable
-{
- private readonly IConnection _connection;
- private SingletonCache _cache;
-
- public ImageStore(IConnection connection)
- {
- _connection = connection;
- _cache = new SingletonCache();
- }
-
- ///
- public async ValueTask PutAsync(Bitmap bitmap)
- {
- using var tx = _connection.BeginTransaction();
- var storedImage = CreateStoredImage(tx, bitmap);
-
- var result = await tx.Commit();
- return result.Remap(storedImage);
- }
-
- ///
- [MustDisposeResource] public Lifetime? Get(OneOf input)
- {
- if (input.TryPickT0(out var id, out var storedImage))
- {
- storedImage = StoredImage.Load(_connection.Db, id);
- }
-
- if (!storedImage.IsValid()) return null;
- var metadata = storedImage.Metadata;
- var bytes = storedImage.ImageData.Data;
-
- Debug.Assert((ulong)bytes.Length == metadata.DataLength);
- var lifetime = _cache.Acquire(id, _ => ToBitmap(metadata, bytes));
- return lifetime;
- }
-
- ///
- StoredImage.New IImageStore.CreateStoredImage(ITransaction transaction, Bitmap bitmap) => CreateStoredImage(transaction, bitmap);
-
- public static StoredImage.New CreateStoredImage(ITransaction transaction, Bitmap bitmap)
- {
- var metadata = ToMetadata(bitmap);
- var bytes = GC.AllocateUninitializedArray(length: (int)metadata.DataLength);
- GetBitmapBytes(metadata, bitmap, bytes);
-
- var imageData = CompressData(metadata, bytes);
-
- var storedImage = new StoredImage.New(transaction)
- {
- Metadata = metadata,
- ImageData = imageData,
- };
-
- return storedImage;
- }
-
- private static ImageData CompressData(ImageMetadata metadata, byte[] uncompressedData)
- {
- // TODO: optional compression for larger images
- return new ImageData(ImageDataCompression.None, uncompressedData);
- }
-
- private static void GetBitmapBytes(ImageMetadata metadata, Bitmap bitmap, byte[] bytes)
- {
- unsafe
- {
- fixed (byte* b = bytes)
- {
- var ptr = new IntPtr(b);
- bitmap.CopyPixels(
- sourceRect: new PixelRect(metadata.PixelSize),
- buffer: ptr,
- bufferSize: (int)metadata.DataLength,
- stride: metadata.Stride
- );
- }
- }
- }
-
- private static Bitmap ToBitmap(ImageMetadata metadata, byte[] bytes)
- {
- unsafe
- {
- fixed (byte* b = bytes)
- {
- var ptr = new IntPtr(b);
- var bitmap = new Bitmap(
- format: metadata.PixelFormat,
- alphaFormat: metadata.AlphaFormat,
- data: ptr,
- size: metadata.PixelSize,
- dpi: new Vector(metadata.Dpi, metadata.Dpi),
- stride: metadata.Stride
- );
-
- return bitmap;
- }
- }
- }
-
- private static ImageMetadata ToMetadata(Bitmap bitmap)
- {
- ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bitmap.PixelSize.Width);
- ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bitmap.PixelSize.Height);
-
- var width = (uint)bitmap.PixelSize.Width;
- var height = (uint)bitmap.PixelSize.Height;
-
- if (!bitmap.Format.HasValue) throw new NotSupportedException("Bitmap doesn't have a PixelFormat");
- var format = bitmap.Format.Value;
-
- if (format.BitsPerPixel % 8 != 0) throw new NotSupportedException($"Format `{format}` isn't supported");
-
- if (!bitmap.AlphaFormat.HasValue) throw new NotSupportedException("Bitmap doesn't have an AlphaFormat");
- var alphaFormat = bitmap.AlphaFormat.Value;
-
- var dpi = bitmap.Dpi;
- var x = (int)Math.Floor(dpi.X);
- var y = (int)Math.Floor(dpi.Y);
- if (x != y) throw new NotSupportedException($"Uneven DPI isn't supported: `{dpi.ToString()}`");
-
- // NOTE(erri120): small hack, Avalonia PixelFormat struct is sealed, and we can't really do anything with it.
- // Instead, we'll just convert to SkColorType using the method provided by Avalonia.
- var skColorType = format.ToSkColorType();
- skColorType.ToPixelFormat();
-
- var metadata = new ImageMetadata(
- imageWidth: width,
- imageHeight: height,
- skColorType: skColorType,
- alphaFormat: alphaFormat,
- dpi: (uint)x
- );
-
- // 16MB is enough to store a RAW image of 2000x2000 with 4 bytes per pixel
- // 8.3MB is enough to store a RAW image of 1920x1080 with 4 bytes per pixel
- var maxSize = Size.MB * 16;
-
- if (Size.From(metadata.DataLength) > maxSize)
- throw new NotSupportedException($"Large images above `{maxSize}` aren't supported!");
-
- return metadata;
- }
-
- private bool _isDisposed;
- public void Dispose()
- {
- ObjectDisposedException.ThrowIf(_isDisposed, this);
-
- _cache = null!;
- _isDisposed = true;
- }
-}
-
diff --git a/src/NexusMods.App.UI/NexusMods.App.UI.csproj b/src/NexusMods.App.UI/NexusMods.App.UI.csproj
index b036424fb..a37c5b47d 100644
--- a/src/NexusMods.App.UI/NexusMods.App.UI.csproj
+++ b/src/NexusMods.App.UI/NexusMods.App.UI.csproj
@@ -85,7 +85,6 @@
-
diff --git a/src/NexusMods.App.UI/Services.cs b/src/NexusMods.App.UI/Services.cs
index f39139163..ec1c5a405 100644
--- a/src/NexusMods.App.UI/Services.cs
+++ b/src/NexusMods.App.UI/Services.cs
@@ -1,7 +1,6 @@
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using NexusMods.Abstractions.Diagnostics;
-using NexusMods.Abstractions.Media;
using NexusMods.Abstractions.Serialization.ExpressionGenerator;
using NexusMods.Abstractions.Serialization.Json;
using NexusMods.App.UI.Controls.DataGrid;
@@ -56,7 +55,6 @@
using NexusMods.App.UI.Windows;
using NexusMods.App.UI.WorkspaceAttachments;
using NexusMods.App.UI.WorkspaceSystem;
-using NexusMods.MnemonicDB.Abstractions;
using NexusMods.Paths;
using ReactiveUI;
using DownloadGameNameView = NexusMods.App.UI.Controls.DownloadGrid.Columns.DownloadGameName.DownloadGameNameView;
@@ -88,8 +86,6 @@ public static IServiceCollection AddUI(this IServiceCollection c)
// Services
.AddSingleton()
- .AddSingleton()
- .AddMedia()
.AddTransient()
// View Models
@@ -211,7 +207,7 @@ public static IServiceCollection AddUI(this IServiceCollection c)
// workspace system
.AddSingleton()
- .AddAttributeCollection(typeof(WindowDataAttributes))
+ .AddWindowDataAttributesModel()
.AddViewModel()
.AddViewModel()
.AddViewModel()
diff --git a/src/NexusMods.DataModel/NexusMods.DataModel.csproj b/src/NexusMods.DataModel/NexusMods.DataModel.csproj
index e8e6b5c71..6d6934f0c 100644
--- a/src/NexusMods.DataModel/NexusMods.DataModel.csproj
+++ b/src/NexusMods.DataModel/NexusMods.DataModel.csproj
@@ -8,6 +8,8 @@
+
+
@@ -16,7 +18,6 @@
-
@@ -34,7 +35,10 @@
+
+
+
@@ -42,8 +46,4 @@
-
-
-
-
diff --git a/src/NexusMods.DataModel/Services.cs b/src/NexusMods.DataModel/Services.cs
index 0589bdf25..3a4a1ddf5 100644
--- a/src/NexusMods.DataModel/Services.cs
+++ b/src/NexusMods.DataModel/Services.cs
@@ -14,6 +14,7 @@
using NexusMods.Abstractions.Jobs;
using NexusMods.Abstractions.Loadouts;
using NexusMods.Abstractions.MnemonicDB.Analyzers;
+using NexusMods.Abstractions.Resources.DB;
using NexusMods.Abstractions.Serialization.ExpressionGenerator;
using NexusMods.DataModel.CommandLine.Verbs;
using NexusMods.DataModel.Diagnostics;
@@ -117,7 +118,9 @@ public static IServiceCollection AddDataModel(this IServiceCollection coll)
// GC
coll.AddAllSingleton();
-
+
+ coll.AddPersistedResourceModel();
+
// Verbs
coll.AddLoadoutManagementVerbs()
.AddToolVerbs();
diff --git a/src/NexusMods.Media/AvaloniaImageLoader.cs b/src/NexusMods.Media/AvaloniaImageLoader.cs
new file mode 100644
index 000000000..7640972ef
--- /dev/null
+++ b/src/NexusMods.Media/AvaloniaImageLoader.cs
@@ -0,0 +1,59 @@
+using Avalonia;
+using Avalonia.Media.Imaging;
+using Avalonia.Skia;
+using NexusMods.Abstractions.Resources;
+using SkiaSharp;
+
+namespace NexusMods.Media;
+
+public sealed class AvaloniaImageLoader : ANestedResourceLoader
+ where TResourceIdentifier : notnull
+{
+ public AvaloniaImageLoader(IResourceLoader innerLoader) : base(innerLoader) { }
+
+ protected override ValueTask> ProcessResourceAsync(
+ Resource resource,
+ TResourceIdentifier resourceIdentifier,
+ CancellationToken cancellationToken)
+ {
+ var skBitmap = resource.Data;
+ var bitmap = ToBitmap(skBitmap);
+
+ return ValueTask.FromResult(new Resource
+ {
+ Data = bitmap,
+ ExpiresAt = resource.ExpiresAt,
+ });
+ }
+
+ private static Bitmap ToBitmap(SKBitmap skBitmap)
+ {
+ var data = skBitmap.GetPixels();
+ ArgumentOutOfRangeException.ThrowIfZero(data);
+
+ var bitmap = new Bitmap(
+ format: skBitmap.ColorType.ToPixelFormat(),
+ alphaFormat: skBitmap.AlphaType.ToAlphaFormat(),
+ data: data,
+ size: new PixelSize(skBitmap.Width, skBitmap.Height),
+ dpi: new Vector(96.0, 96.0),
+ stride: skBitmap.RowBytes
+ );
+
+ return bitmap;
+ }
+}
+
+public static partial class Extensions
+{
+ public static IResourceLoader ToAvaloniaBitmap(
+ this IResourceLoader inner)
+ where TResourceIdentifier : notnull
+ {
+ return inner.Then(
+ factory: static inner => new AvaloniaImageLoader(
+ innerLoader: inner
+ )
+ );
+ }
+}
diff --git a/src/NexusMods.Media/ImageDecoder.cs b/src/NexusMods.Media/ImageDecoder.cs
new file mode 100644
index 000000000..a5defbd9c
--- /dev/null
+++ b/src/NexusMods.Media/ImageDecoder.cs
@@ -0,0 +1,86 @@
+using NexusMods.Abstractions.Resources;
+using SkiaSharp;
+
+namespace NexusMods.Media;
+
+public sealed class ImageDecoder : ANestedResourceLoader
+ where TResourceIdentifier : notnull
+{
+ private readonly DecoderType _decoderType;
+
+ public ImageDecoder(
+ DecoderType decoderType,
+ IResourceLoader innerLoader
+ ) : base(innerLoader)
+ {
+ _decoderType = decoderType;
+ }
+
+ protected override ValueTask> ProcessResourceAsync(
+ Resource resource,
+ TResourceIdentifier resourceIdentifier,
+ CancellationToken cancellationToken)
+ {
+ var decoded = Decode(resource.Data, _decoderType);
+ return ValueTask.FromResult(new Resource
+ {
+ Data = decoded,
+ ExpiresAt = resource.ExpiresAt,
+ });
+ }
+
+ private static SKBitmap Decode(byte[] data, DecoderType format)
+ {
+ return format switch
+ {
+ DecoderType.Qoi => DecodeQoi(),
+ DecoderType.Skia => SKBitmap.Decode(data),
+ };
+
+ unsafe SKBitmap DecodeQoi()
+ {
+ var qoiImage = QoiSharp.QoiDecoder.Decode(data);
+
+ var skImageInfo = new SKImageInfo(
+ width: qoiImage.Width,
+ height: qoiImage.Height,
+ colorType: SKColorType.Rgba8888,
+ alphaType: SKAlphaType.Unpremul,
+ colorspace: SKColorSpace.CreateSrgbLinear()
+ );
+
+ var skBitmap = new SKBitmap(skImageInfo);
+
+ var pixels = skBitmap.GetPixels(out var length);
+ ArgumentOutOfRangeException.ThrowIfZero(pixels);
+
+ var span = new Span((void*) pixels, (int) length);
+ qoiImage.Data.CopyTo(span);
+
+ return skBitmap;
+ }
+ }
+}
+
+public static partial class Extensions
+{
+ public static IResourceLoader Decode(
+ this IResourceLoader inner,
+ DecoderType decoderType)
+ where TResourceIdentifier : notnull
+ {
+ return inner.Then(
+ state: decoderType,
+ factory: static (decoderType, inner) => new ImageDecoder(
+ decoderType: decoderType,
+ innerLoader: inner
+ )
+ );
+ }
+}
+
+public enum DecoderType
+{
+ Qoi = 0,
+ Skia = 1,
+}
diff --git a/src/NexusMods.Media/ImageEncoder.cs b/src/NexusMods.Media/ImageEncoder.cs
new file mode 100644
index 000000000..2430b0ec4
--- /dev/null
+++ b/src/NexusMods.Media/ImageEncoder.cs
@@ -0,0 +1,102 @@
+using NexusMods.Abstractions.Resources;
+using SkiaSharp;
+
+namespace NexusMods.Media;
+
+public sealed class ImageEncoder : ANestedResourceLoader
+ where TResourceIdentifier : notnull
+{
+ private readonly EncoderType _encoderType;
+
+ public ImageEncoder(
+ EncoderType encoderType,
+ IResourceLoader innerLoader) : base(innerLoader)
+ {
+ _encoderType = encoderType;
+ }
+
+ protected override ValueTask> ProcessResourceAsync(
+ Resource resource,
+ TResourceIdentifier resourceIdentifier,
+ CancellationToken cancellationToken)
+ {
+ var skBitmap = resource.Data;
+
+ var encoded = _encoderType switch
+ {
+ EncoderType.Qoi => ToQoi(),
+ EncoderType.SkiaWebp => WithSkia(),
+ };
+
+ return ValueTask.FromResult(new Resource
+ {
+ Data = encoded,
+ ExpiresAt = resource.ExpiresAt,
+ });
+
+ byte[] WithSkia()
+ {
+ using var ms = new MemoryStream();
+
+ var skiaFormat = _encoderType switch
+ {
+ EncoderType.SkiaWebp => SKEncodedImageFormat.Webp,
+ EncoderType.Qoi => throw new NotSupportedException(),
+ };
+
+ resource.Data.Encode(ms, skiaFormat, quality: 80);
+ return ms.ToArray();
+ }
+
+ byte[] ToQoi()
+ {
+ var pixels = PreparePixels(skBitmap);
+ var qoiImage = new QoiSharp.QoiImage(
+ data: pixels,
+ width: skBitmap.Width,
+ height: skBitmap.Height,
+ channels: QoiSharp.Codec.Channels.RgbWithAlpha,
+ colorSpace: QoiSharp.Codec.ColorSpace.SRgb
+ );
+
+ return QoiSharp.QoiEncoder.Encode(qoiImage);
+ }
+ }
+
+ private static byte[] PreparePixels(SKBitmap skBitmap)
+ {
+ // TODO: check if input is already in the correct layout, then we can skip this transposing step
+ var skImageInfo = skBitmap.Info
+ .WithAlphaType(SKAlphaType.Unpremul)
+ .WithColorSpace(SKColorSpace.CreateSrgbLinear())
+ .WithColorType(SKColorType.Rgba8888);
+
+ using var outputSkBitmap = new SKBitmap(skImageInfo);
+ skBitmap.CopyTo(outputSkBitmap);
+
+ return skBitmap.GetPixelSpan().ToArray();
+ }
+}
+
+public static partial class Extensions
+{
+ public static IResourceLoader Encode(
+ this IResourceLoader inner,
+ EncoderType encoderType)
+ where TResourceIdentifier : notnull
+ {
+ return inner.Then(
+ state: encoderType,
+ factory: static (encoderType, inner) => new ImageEncoder(
+ encoderType: encoderType,
+ innerLoader: inner
+ )
+ );
+ }
+}
+
+public enum EncoderType : byte
+{
+ Qoi = 0,
+ SkiaWebp = 1,
+}
diff --git a/src/NexusMods.Media/ImageResizer.cs b/src/NexusMods.Media/ImageResizer.cs
new file mode 100644
index 000000000..cf1b2c07f
--- /dev/null
+++ b/src/NexusMods.Media/ImageResizer.cs
@@ -0,0 +1,45 @@
+using NexusMods.Abstractions.Resources;
+using SkiaSharp;
+
+namespace NexusMods.Media;
+
+public sealed class ImageResizer : ANestedResourceLoader
+ where TResourceIdentifier : notnull
+{
+ private readonly SKSizeI _newSize;
+
+ public ImageResizer(
+ SKSizeI newSize,
+ IResourceLoader innerLoader) : base(innerLoader)
+ {
+ _newSize = newSize;
+ }
+
+ protected override ValueTask> ProcessResourceAsync(
+ Resource resource,
+ TResourceIdentifier resourceIdentifier,
+ CancellationToken cancellationToken)
+ {
+ return ValueTask.FromResult(resource with
+ {
+ Data = resource.Data.Resize(_newSize, quality: SKFilterQuality.Low),
+ });
+ }
+}
+
+public static partial class Extensions
+{
+ public static IResourceLoader Resize(
+ this IResourceLoader inner,
+ SKSizeI newSize)
+ where TResourceIdentifier : notnull
+ {
+ return inner.Then(
+ state: newSize,
+ factory: static (newSize, inner) => new ImageResizer(
+ newSize: newSize,
+ innerLoader: inner
+ )
+ );
+ }
+}
diff --git a/src/NexusMods.Media/NexusMods.Media.csproj b/src/NexusMods.Media/NexusMods.Media.csproj
new file mode 100644
index 000000000..b3ca3095f
--- /dev/null
+++ b/src/NexusMods.Media/NexusMods.Media.csproj
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/NexusMods.StandardGameLocators/SteamLocator.cs b/src/NexusMods.StandardGameLocators/SteamLocator.cs
index cf8d35ac7..eb029c491 100644
--- a/src/NexusMods.StandardGameLocators/SteamLocator.cs
+++ b/src/NexusMods.StandardGameLocators/SteamLocator.cs
@@ -23,7 +23,10 @@ public SteamLocator(IServiceProvider provider) : base(provider) { }
protected override IEnumerable Ids(ISteamGame game) => game.SteamIds.Select(AppId.From);
///
- protected override AbsolutePath Path(SteamGame record) => record.Path;
+ protected override AbsolutePath Path(SteamGame game)
+ {
+ return game.Path;
+ }
///
protected override IFileSystem GetMappedFileSystem(SteamGame game)
diff --git a/tests/NexusMods.UI.Tests/ImageLoaderTests.cs b/tests/NexusMods.UI.Tests/ImageLoaderTests.cs
new file mode 100644
index 000000000..b41ecd204
--- /dev/null
+++ b/tests/NexusMods.UI.Tests/ImageLoaderTests.cs
@@ -0,0 +1,85 @@
+using Avalonia.Media.Imaging;
+using BitFaster.Caching;
+using BitFaster.Caching.Lru;
+using FluentAssertions;
+using Microsoft.Extensions.DependencyInjection;
+using NexusMods.Abstractions.NexusModsLibrary;
+using NexusMods.Abstractions.NexusWebApi.Types;
+using NexusMods.Abstractions.Resources;
+using NexusMods.Abstractions.Resources.Caching;
+using NexusMods.Abstractions.Resources.DB;
+using NexusMods.Abstractions.Resources.IO;
+using NexusMods.Games.RedEngine.Cyberpunk2077;
+using NexusMods.Hashing.xxHash64;
+using NexusMods.Media;
+using NexusMods.MnemonicDB.Abstractions;
+using NexusMods.Networking.NexusWebApi;
+using SkiaSharp;
+
+namespace NexusMods.UI.Tests;
+
+public class ImageLoaderTests : AUiTest
+{
+ public ImageLoaderTests(IServiceProvider provider) : base(provider) { }
+
+ [Fact]
+ [Trait("RequiresNetworking", "True")]
+ public async Task Test()
+ {
+ var library = Provider.GetRequiredService();
+ var modPage = await library.GetOrAddModPage(ModId.From(6072), Cyberpunk2077Game.StaticDomain);
+
+ {
+ var pipeline = CreatePipeline();
+
+ using var lifetime1 = (await pipeline.LoadResourceAsync(modPage, CancellationToken.None)).Data;
+ using var lifetime2 = (await pipeline.LoadResourceAsync(modPage, CancellationToken.None)).Data;
+ lifetime1.Value.Should().BeSameAs(lifetime2.Value);
+ lifetime1.ReferenceCount.Should().Be(1);
+ lifetime2.ReferenceCount.Should().Be(2);
+
+ lifetime1.Value.Size.Should().Be(new Avalonia.Size(120, 80));
+ }
+
+ {
+ var pipeline = CreatePipeline();
+
+ using var lifetime = (await pipeline.LoadResourceAsync(modPage, CancellationToken.None)).Data;
+ lifetime.Value.Size.Should().Be(new Avalonia.Size(120, 80));
+ }
+ }
+
+ private IResourceLoader> CreatePipeline()
+ {
+ const byte partitionId = 123;
+
+ var pipeline = new HttpLoader(new HttpClient())
+ .ChangeIdentifier, Uri, byte[]>(static tuple => tuple.Item2)
+ .Decode(decoderType: DecoderType.Skia)
+ .Resize(newSize: new SKSizeI(
+ width: 120,
+ height: 80
+ ))
+ .Encode(encoderType: EncoderType.Qoi)
+ .Persist(
+ connection: Connection,
+ referenceAttribute: NexusModsModPageMetadata.ThumbnailResource,
+ identifierToHash: static tuple => tuple.Item2.ToString().XxHash64AsUtf8(),
+ identifierToEntityId: static tuple => tuple.Item1,
+ partitionId: PartitionId.User(partitionId)
+ )
+ .Decode(decoderType: DecoderType.Qoi)
+ .ToAvaloniaBitmap()
+ .UseScopedCache(
+ keyGenerator: static tuple => tuple.Item1,
+ keyComparer: EqualityComparer.Default,
+ capacityPartition: new FavorWarmPartition(totalCapacity: 10, warmRatio: FavorWarmPartition.DefaultWarmRatio)
+ )
+ .EntityIdToIdentifier(
+ connection: Connection,
+ attribute: NexusModsModPageMetadata.FullSizedPictureUri
+ );
+
+ return pipeline;
+ }
+}
diff --git a/tests/NexusMods.UI.Tests/ImageStoreTests.cs b/tests/NexusMods.UI.Tests/ImageStoreTests.cs
deleted file mode 100644
index f418f04a6..000000000
--- a/tests/NexusMods.UI.Tests/ImageStoreTests.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using Avalonia.Media.Imaging;
-using Avalonia.Platform;
-using Avalonia.Skia;
-using FluentAssertions;
-using Microsoft.Extensions.DependencyInjection;
-using NexusMods.Abstractions.Media;
-
-namespace NexusMods.UI.Tests;
-
-public class ImageStoreTests : AUiTest
-{
- private readonly IImageStore _imageStore;
-
- public ImageStoreTests(IServiceProvider serviceProvider) : base(serviceProvider)
- {
- _imageStore = serviceProvider.GetRequiredService();
- }
-
- [Fact]
- public async Task SimpleTest()
- {
- var bitmap = new Bitmap(AssetLoader.Open(new Uri("avares://NexusMods.App.UI/Assets/DesignTime/cyberpunk_game.png")));
- var storedImage = await _imageStore.PutAsync(bitmap);
- using var lifetime = _imageStore.Get(storedImage);
- lifetime.Should().NotBeNull();
-
- var result = lifetime!.Value;
- result.PixelSize.Equals(bitmap.PixelSize).Should().BeTrue();
- result.Dpi.NearlyEquals(bitmap.Dpi).Should().BeTrue();
- result.AlphaFormat.Should().Be(bitmap.AlphaFormat);
- result.Format.Should().NotBeNull();
- result.Format!.Value.ToSkColorType().Should().Be(bitmap.Format!.Value.ToSkColorType());
- }
-}
diff --git a/tests/NexusMods.UI.Tests/NexusMods.UI.Tests.csproj b/tests/NexusMods.UI.Tests/NexusMods.UI.Tests.csproj
index f67ba82d6..1373d5d28 100644
--- a/tests/NexusMods.UI.Tests/NexusMods.UI.Tests.csproj
+++ b/tests/NexusMods.UI.Tests/NexusMods.UI.Tests.csproj
@@ -1,10 +1,14 @@
+
+
+
+
diff --git a/tests/NexusMods.UI.Tests/Startup.cs b/tests/NexusMods.UI.Tests/Startup.cs
index 097792716..33de079a5 100644
--- a/tests/NexusMods.UI.Tests/Startup.cs
+++ b/tests/NexusMods.UI.Tests/Startup.cs
@@ -3,9 +3,7 @@
using NexusMods.Abstractions.Settings;
using NexusMods.App;
using NexusMods.App.BuildInfo;
-using NexusMods.App.UI.Settings;
using NexusMods.DataModel;
-using NexusMods.Games.RedEngine;
using NexusMods.Games.RedEngine.Cyberpunk2077;
using NexusMods.Paths;
using NexusMods.StandardGameLocators.TestHelpers;