diff --git a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Harness/DummyFileStore.cs b/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Harness/DummyFileStore.cs index 12582b3231..666cc9fa1f 100644 --- a/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Harness/DummyFileStore.cs +++ b/benchmarks/NexusMods.Benchmarks/Benchmarks/Loadouts/Harness/DummyFileStore.cs @@ -16,6 +16,11 @@ public Task BackupFiles(IEnumerable backups, bool deduplicate return Task.CompletedTask; } + public Task BackupFiles(string archiveName, IEnumerable files, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + public Task ExtractFiles(IEnumerable<(Hash Hash, AbsolutePath Dest)> files, CancellationToken token = default) { return Task.CompletedTask; diff --git a/src/Abstractions/NexusMods.Abstractions.IO/IFileStore.cs b/src/Abstractions/NexusMods.Abstractions.IO/IFileStore.cs index 8ea5829f85..da846b6842 100644 --- a/src/Abstractions/NexusMods.Abstractions.IO/IFileStore.cs +++ b/src/Abstractions/NexusMods.Abstractions.IO/IFileStore.cs @@ -44,6 +44,12 @@ public interface IFileStore /// Task BackupFiles(IEnumerable backups, bool deduplicate = true, CancellationToken token = default); + /// + /// Similar to + /// except the same archive is used. + /// + Task BackupFiles(string archiveName, IEnumerable files, CancellationToken cancellationToken = default); + /// /// Extract the given files to the given disk locations, provide as a less-abstract interface incase /// the extractor needs more direct access (such as memory mapping). diff --git a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Models/CollectionMetadata.cs b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Models/CollectionMetadata.cs index 4b2b05198c..57a0098e9d 100644 --- a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Models/CollectionMetadata.cs +++ b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/Models/CollectionMetadata.cs @@ -1,5 +1,6 @@ using NexusMods.Abstractions.MnemonicDB.Attributes; using NexusMods.Abstractions.NexusModsLibrary.Attributes; +using NexusMods.Abstractions.Resources.DB; using NexusMods.MnemonicDB.Abstractions.Attributes; using NexusMods.MnemonicDB.Abstractions.Models; @@ -46,14 +47,24 @@ public partial class CollectionMetadata : IModelDefinition /// The number of endorsements the collection has. /// public static readonly ULongAttribute Endorsements = new(Namespace, nameof(Endorsements)); - + /// - /// The collections' image. + /// The tile image uri. /// - public static readonly MemoryAttribute TileImage = new(Namespace, nameof(TileImage)); - + public static readonly UriAttribute TileImageUri = new(Namespace, nameof(TileImageUri)) { IsOptional = true }; + + /// + /// The background image uri. + /// + public static readonly UriAttribute BackgroundImageUri = new(Namespace, nameof(BackgroundImageUri)) { IsOptional = true }; + + /// + /// The tile image resource. + /// + public static readonly ReferenceAttribute TileImageResource = new(Namespace, nameof(TileImageResource)) { IsOptional = true }; + /// - /// The collections' image. + /// The background image resource. /// - public static readonly MemoryAttribute BackgroundImage = new(Namespace, nameof(BackgroundImage)); + public static readonly ReferenceAttribute BackgroundImageResource = new(Namespace, nameof(BackgroundImageResource)) { IsOptional = true }; } diff --git a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsModPageMetadata.cs b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsModPageMetadata.cs index 6d4cd1a752..f92f049f77 100644 --- a/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsModPageMetadata.cs +++ b/src/Abstractions/NexusMods.Abstractions.NexusModsLibrary/NexusModsModPageMetadata.cs @@ -36,7 +36,7 @@ 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 }; + 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.DB/PersistedResource.cs b/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedDbResource.cs similarity index 60% rename from src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResource.cs rename to src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedDbResource.cs index 85f0433850..b37b116349 100644 --- a/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResource.cs +++ b/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedDbResource.cs @@ -4,19 +4,34 @@ namespace NexusMods.Abstractions.Resources.DB; +/// +/// Represents a resources persisted in the database. +/// [PublicAPI] -public partial class PersistedResource : IModelDefinition +public partial class PersistedDbResource : IModelDefinition { private const string Namespace = "NexusMods.Resources.PersistedResource"; + /// + /// The raw data. + /// public static readonly BytesAttribute Data = new(Namespace, nameof(Data)); + /// + /// The expiration date. + /// public static readonly DateTimeAttribute ExpiresAt = new(Namespace, nameof(ExpiresAt)); + /// + /// The resource identifier as a hash. + /// public static readonly HashAttribute ResourceIdentifierHash = new(Namespace, nameof(ResourceIdentifierHash)); public partial struct ReadOnly { + /// + /// Whether the resource is expired. + /// public bool IsExpired => DateTime.UtcNow > ExpiresAt; } } diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResourceLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedDbResourceLoader.cs similarity index 57% rename from src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResourceLoader.cs rename to src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedDbResourceLoader.cs index 8116ae9c2f..8dc2c813df 100644 --- a/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedResourceLoader.cs +++ b/src/Abstractions/NexusMods.Abstractions.Resources.DB/PersistedDbResourceLoader.cs @@ -6,43 +6,45 @@ namespace NexusMods.Abstractions.Resources.DB; +/// +/// Loads persisted resources from the database. +/// [PublicAPI] -public sealed class PersistedResourceLoader : IResourceLoader +public sealed class PersistedDbResourceLoader : IResourceLoader where TResourceIdentifier : notnull - where TData : notnull { - public delegate byte[] DataToBytes(TData data); - public delegate TData BytesToData(byte[] bytes); + /// + /// Converts the identifier to a hash. + /// public delegate Hash IdentifierToHash(TResourceIdentifier resourceIdentifier); + /// + /// Converts the identifier into an entity id. + /// 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 IResourceLoader _innerLoader; + private readonly ReferenceAttribute _referenceAttribute; private readonly IdentifierToHash _identifierToHash; private readonly IdentifierToEntityId _identifierToEntityId; private readonly AttributeId _referenceAttributeId; private readonly Optional _partitionId; - public PersistedResourceLoader( + /// + /// Constructor. + /// + public PersistedDbResourceLoader( IConnection connection, - ReferenceAttribute referenceAttribute, + ReferenceAttribute referenceAttribute, IdentifierToHash identifierToHash, - DataToBytes dataToBytes, - BytesToData bytesToData, IdentifierToEntityId identifierToEntityId, Optional partitionId, - IResourceLoader innerLoader) + IResourceLoader innerLoader) { _connection = connection; _innerLoader = innerLoader; - _dataToBytes = dataToBytes; - _bytesToData = bytesToData; - _identifierToHash = identifierToHash; _identifierToEntityId = identifierToEntityId; @@ -52,7 +54,7 @@ public PersistedResourceLoader( } /// - public ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken) + public ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken) { var entityId = _identifierToEntityId(resourceIdentifier); var tuple = (entityId, resourceIdentifier); @@ -62,7 +64,7 @@ public ValueTask> LoadResourceAsync(TResourceIdentifier resource return SaveResource(tuple, cancellationToken); } - private Resource? LoadResource(ValueTuple resourceIdentifier) + private Resource? LoadResource(ValueTuple resourceIdentifier) { var db = _connection.Db; var (entityId, innerResourceIdentifier) = resourceIdentifier; @@ -76,7 +78,7 @@ public ValueTask> LoadResourceAsync(TResourceIdentifier resource } if (!persistedResourceId.HasValue) return null; - var persistedResource = PersistedResource.Load(db, persistedResourceId.Value); + var persistedResource = PersistedDbResource.Load(db, persistedResourceId.Value); if (!persistedResource.IsValid()) return null; if (persistedResource.IsExpired) return null; @@ -85,26 +87,24 @@ public ValueTask> LoadResourceAsync(TResourceIdentifier resource if (!persistedResource.ResourceIdentifierHash.Equals(hash)) return null; var bytes = persistedResource.Data; - var data = _bytesToData(bytes); - return new Resource + return new Resource { - Data = data, + Data = bytes, ExpiresAt = persistedResource.ExpiresAt, }; } - private async ValueTask> SaveResource(ValueTuple resourceIdentifier, CancellationToken cancellationToken) + 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) + var persisted = new PersistedDbResource.New(tx, tmpId) { - Data = bytes, + Data = resource.Data, ExpiresAt = resource.ExpiresAt, ResourceIdentifierHash = _identifierToHash(resourceIdentifier.Item2), }; @@ -122,47 +122,49 @@ private async ValueTask> SaveResource(ValueTuple Persist( + /// + /// Persist the resource in the database. + /// + public static IResourceLoader PersistInDb( this IResourceLoader inner, IConnection connection, - ReferenceAttribute referenceAttribute, - PersistedResourceLoader.IdentifierToHash identifierToHash, - PersistedResourceLoader.IdentifierToEntityId identifierToEntityId, + ReferenceAttribute referenceAttribute, + PersistedDbResourceLoader.IdentifierToHash identifierToHash, + PersistedDbResourceLoader.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 + return inner.Then( + state: (connection, referenceAttribute, identifierToHash, identifierToEntityId, partitionId), + factory: static (input, inner) => new PersistedDbResourceLoader( + connection: input.connection, + referenceAttribute: input.referenceAttribute, + identifierToHash: input.identifierToHash, + identifierToEntityId: input.identifierToEntityId, + partitionId: input.partitionId, + innerLoader: inner + ) ); } - public static IResourceLoader Persist( - this IResourceLoader inner, + /// + /// Persist the resource in the database. + /// + public static IResourceLoader, byte[]> PersistInDb( + this IResourceLoader, byte[]> inner, IConnection connection, - ReferenceAttribute referenceAttribute, - PersistedResourceLoader.IdentifierToHash identifierToHash, - PersistedResourceLoader.IdentifierToEntityId identifierToEntityId, - PersistedResourceLoader.DataToBytes dataToBytes, - PersistedResourceLoader.BytesToData bytesToData, + ReferenceAttribute referenceAttribute, + Func identifierToHash, 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( + state: (connection, referenceAttribute, identifierToHash, partitionId), + factory: static (input, inner) => new PersistedDbResourceLoader>( connection: input.connection, referenceAttribute: input.referenceAttribute, - identifierToHash: input.identifierToHash, - identifierToEntityId: input.identifierToEntityId, - dataToBytes: input.dataToBytes, - bytesToData: input.bytesToData, + identifierToHash: tuple => input.identifierToHash(tuple.Item2), + identifierToEntityId: static tuple => tuple.Item1, partitionId: input.partitionId, innerLoader: inner ) diff --git a/src/Abstractions/NexusMods.Abstractions.Resources.IO/PersistedFileResourceLoader.cs b/src/Abstractions/NexusMods.Abstractions.Resources.IO/PersistedFileResourceLoader.cs new file mode 100644 index 0000000000..6c86b9873d --- /dev/null +++ b/src/Abstractions/NexusMods.Abstractions.Resources.IO/PersistedFileResourceLoader.cs @@ -0,0 +1,92 @@ +using JetBrains.Annotations; +using NexusMods.Hashing.xxHash64; +using NexusMods.Paths; + +namespace NexusMods.Abstractions.Resources.IO; + +/// +/// Loads a persisted file from disk. +/// +[PublicAPI] +public sealed class PersistedFileResourceLoader : IResourceLoader + where TResourceIdentifier : notnull +{ + /// + /// Converts the resource identifier to a hash. + /// + public delegate Hash IdentifierToHash(TResourceIdentifier resourceIdentifier); + + private readonly AbsolutePath _directory; + private readonly Extension _extension; + private readonly IdentifierToHash _identifierToHash; + private readonly IResourceLoader _innerLoader; + + /// + /// Constructor. + /// + public PersistedFileResourceLoader( + AbsolutePath directory, + Extension extension, + IdentifierToHash identifierToHash, + IResourceLoader innerLoader) + { + _directory = directory; + _extension = extension; + _identifierToHash = identifierToHash; + _innerLoader = innerLoader; + } + + /// + public async ValueTask> LoadResourceAsync(TResourceIdentifier resourceIdentifier, CancellationToken cancellationToken) + { + var hash = _identifierToHash(resourceIdentifier); + var path = _directory.Combine($"{hash.ToHex()}{_extension.ToString()}"); + + var hasFile = path.FileExists; + await using var stream = path.Open(FileMode.Create, FileAccess.ReadWrite, FileShare.None); + + if (hasFile) + { + var bytes = GC.AllocateUninitializedArray(length: (int)stream.Length); + await stream.ReadExactlyAsync(bytes, cancellationToken); + + return new Resource + { + Data = bytes, + }; + } + + var resource = await _innerLoader.LoadResourceAsync(resourceIdentifier, cancellationToken); + await stream.WriteAsync(resource.Data, cancellationToken); + + return resource; + } +} + +/// +/// Extension methods. +/// +[PublicAPI] +public static class ExtensionMethods +{ + /// + /// Persist the resource on disk. + /// + public static IResourceLoader PersistOnDisk( + this IResourceLoader inner, + AbsolutePath directory, + Extension extension, + PersistedFileResourceLoader.IdentifierToHash identifierToHash) + where TResourceIdentifier : notnull + { + return inner.Then( + state: (directory, extension, identifierToHash), + factory: static (input, inner) => new PersistedFileResourceLoader( + directory: input.directory, + extension: input.extension, + identifierToHash: input.identifierToHash, + innerLoader: inner + ) + ); + } +} diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.cs b/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.cs index 93e841f13e..4047b7794c 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.cs @@ -75,17 +75,25 @@ public NexusModsLibrary(IServiceProvider serviceProvider) var db = _connection.Db; var collectionInfo = info.Data!.CollectionRevision.Collection; - var collectionTileImage = DownloadImage(collectionInfo.TileImage?.ThumbnailUrl, token); - var collectionBackgroundImage = DownloadImage(collectionInfo.HeaderImage?.Url, token); // Remap the collection info var collectionResolver = GraphQLResolver.Create(db, tx, CollectionMetadata.Slug, slug); collectionResolver.Add(CollectionMetadata.Name, collectionInfo.Name); collectionResolver.Add(CollectionMetadata.Summary, collectionInfo.Summary); collectionResolver.Add(CollectionMetadata.Endorsements, (ulong)collectionInfo.Endorsements); - collectionResolver.Add(CollectionMetadata.TileImage, await collectionTileImage); - collectionResolver.Add(CollectionMetadata.BackgroundImage, await collectionBackgroundImage); - + + var thumbnailUrl = collectionInfo.TileImage?.ThumbnailUrl; + if (thumbnailUrl is not null && Uri.TryCreate(thumbnailUrl, UriKind.Absolute, out var thumbnailUri)) + { + collectionResolver.Add(CollectionMetadata.TileImageUri, thumbnailUri); + } + + var headerImageUrl = collectionInfo.HeaderImage?.Url; + if (headerImageUrl is not null && Uri.TryCreate(headerImageUrl, UriKind.Absolute, out var headerImageUri)) + { + collectionResolver.Add(CollectionMetadata.BackgroundImageUri, headerImageUri); + } + var user = await collectionInfo.User.Resolve(db, tx, _httpClient, token); collectionResolver.Add(CollectionMetadata.Author, user); @@ -116,14 +124,6 @@ public NexusModsLibrary(IServiceProvider serviceProvider) var txResults = await tx.Commit(); return CollectionRevisionMetadata.Load(txResults.Db, txResults[revisionResolver.Id]); } - - private async Task DownloadImage(string? uri, CancellationToken token) - { - if (uri is null) return []; - if (!Uri.TryCreate(uri, UriKind.Absolute, out var imageUri)) return []; - - return await _httpClient.GetByteArrayAsync(imageUri, token); - } public async Task GetOrAddFile( FileId fileId, diff --git a/src/NexusMods.App.UI/Assets/collection-tile-fallback.png b/src/NexusMods.App.UI/Assets/collection-tile-fallback.png new file mode 100644 index 0000000000..73e9bb3150 Binary files /dev/null and b/src/NexusMods.App.UI/Assets/collection-tile-fallback.png differ diff --git a/src/NexusMods.App.UI/ImagePipelines.cs b/src/NexusMods.App.UI/ImagePipelines.cs new file mode 100644 index 0000000000..321ef66541 --- /dev/null +++ b/src/NexusMods.App.UI/ImagePipelines.cs @@ -0,0 +1,93 @@ +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Microsoft.Extensions.DependencyInjection; +using NexusMods.Abstractions.NexusModsLibrary.Models; +using NexusMods.Abstractions.Resources; +using NexusMods.Abstractions.Resources.DB; +using NexusMods.Abstractions.Resources.IO; +using NexusMods.Abstractions.Resources.Resilience; +using NexusMods.Hashing.xxHash64; +using NexusMods.Media; +using NexusMods.MnemonicDB.Abstractions; + +namespace NexusMods.App.UI; + +internal static class ImagePipelines +{ + private const byte ImagePartitionId = 10; + private const string CollectionTileImagePipelineKey = nameof(CollectionTileImagePipelineKey); + private const string CollectionBackgroundImagePipelineKey = nameof(CollectionBackgroundImagePipelineKey); + + private static readonly Bitmap CollectionTileFallback = new(AssetLoader.Open(new Uri("avares://NexusMods.App.UI/Assets/collection-tile-fallback.png"))); + + public static IServiceCollection AddImagePipelines(this IServiceCollection serviceCollection) + { + return serviceCollection + .AddKeyedSingleton>( + serviceKey: CollectionTileImagePipelineKey, + implementationFactory: static (serviceProvider, _) => CreateCollectionTileImagePipeline( + connection: serviceProvider.GetRequiredService() + ) + ) + .AddKeyedSingleton>( + serviceKey: CollectionBackgroundImagePipelineKey, + implementationFactory: static (serviceProvider, _) => CreateCollectionBackgroundImagePipeline( + connection: serviceProvider.GetRequiredService() + ) + ); + } + + public static IResourceLoader GetCollectionTileImagePipeline(IServiceProvider serviceProvider) + { + return serviceProvider.GetRequiredKeyedService>(serviceKey: CollectionTileImagePipelineKey); + } + + public static IResourceLoader GetCollectionBackgroundImagePipeline(IServiceProvider serviceProvider) + { + return serviceProvider.GetRequiredKeyedService>(serviceKey: CollectionBackgroundImagePipelineKey); + } + + private static IResourceLoader CreateCollectionTileImagePipeline( + IConnection connection) + { + var pipeline = new HttpLoader(new HttpClient()) + .ChangeIdentifier, Uri, byte[]>(static tuple => tuple.Item2) + .PersistInDb( + connection: connection, + referenceAttribute: CollectionMetadata.TileImageResource, + identifierToHash: static uri => uri.ToString().XxHash64AsUtf8(), + partitionId: PartitionId.User(ImagePartitionId) + ) + .Decode(decoderType: DecoderType.Skia) + .ToAvaloniaBitmap() + .UseFallbackValue(CollectionTileFallback) + .EntityIdToIdentifier( + connection: connection, + attribute: CollectionMetadata.TileImageUri + ); + + return pipeline; + } + + private static IResourceLoader CreateCollectionBackgroundImagePipeline( + IConnection connection) + { + var pipeline = new HttpLoader(new HttpClient()) + .ChangeIdentifier, Uri, byte[]>(static tuple => tuple.Item2) + .PersistInDb( + connection: connection, + referenceAttribute: CollectionMetadata.BackgroundImageResource, + identifierToHash: static uri => uri.ToString().XxHash64AsUtf8(), + partitionId: PartitionId.User(ImagePartitionId) + ) + .Decode(decoderType: DecoderType.Skia) + .ToAvaloniaBitmap() + // TODO: .UseFallbackValue() + .EntityIdToIdentifier( + connection: connection, + attribute: CollectionMetadata.BackgroundImageUri + ); + + return pipeline; + } +} diff --git a/src/NexusMods.App.UI/NexusMods.App.UI.csproj b/src/NexusMods.App.UI/NexusMods.App.UI.csproj index a37c5b47df..08f935af34 100644 --- a/src/NexusMods.App.UI/NexusMods.App.UI.csproj +++ b/src/NexusMods.App.UI/NexusMods.App.UI.csproj @@ -86,6 +86,9 @@ + + + @@ -94,6 +97,7 @@ + diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardDesignViewModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardDesignViewModel.cs index 17a7cda03f..65935a0e93 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardDesignViewModel.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardDesignViewModel.cs @@ -8,7 +8,7 @@ namespace NexusMods.App.UI.Pages.LibraryPage.Collections; public class CollectionCardDesignViewModel : AViewModel, ICollectionCardViewModel { public string Name => "Vanilla+ [Quality of Life]"; - public Bitmap Image => new(AssetLoader.Open(new Uri("avares://NexusMods.App.UI/Assets/DesignTime/collection_tile_image.png"))); + public Bitmap? Image => new(AssetLoader.Open(new Uri("avares://NexusMods.App.UI/Assets/DesignTime/collection_tile_image.png"))); public string Summary => "1.6.8 This visual mod collection is designed to create a witchy medieval cottage aethetic experience for Stardew Valley, and Stardew Valley Expanded."; public string Category => "All-in-One \u2022 Fair and Balanced \u2022 Gameplay \u2022 Lore-friendly"; public int ModCount => 9; diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardViewModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardViewModel.cs index 4a29b31756..b8b0278919 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardViewModel.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionCardViewModel.cs @@ -1,10 +1,13 @@ using Avalonia.Media.Imaging; -using Avalonia.Platform; using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.NexusModsLibrary.Models; using NexusMods.Abstractions.NexusWebApi.Types; +using NexusMods.Abstractions.Resources; using NexusMods.MnemonicDB.Abstractions; using NexusMods.Paths; +using R3; +using ReactiveUI; +using ReactiveUI.Fody.Helpers; namespace NexusMods.App.UI.Pages.LibraryPage.Collections; @@ -13,15 +16,29 @@ public class CollectionCardViewModel : AViewModel, ICo private readonly CollectionRevisionMetadata.ReadOnly _revision; private readonly CollectionMetadata.ReadOnly _collection; - public CollectionCardViewModel(IConnection connection, RevisionId revision) + public CollectionCardViewModel( + IResourceLoader tileImagePipeline, + IConnection connection, + RevisionId revision) { - _revision = CollectionRevisionMetadata.FindByRevisionId(connection.Db, revision) - .First(); + _revision = CollectionRevisionMetadata.FindByRevisionId(connection.Db, revision).First(); _collection = _revision.Collection; + + this.WhenActivated(disposables => + { + Observable + .Return(_collection.Id) + .ObserveOnThreadPool() + .SelectAwait(async (id, cancellationToken) => await tileImagePipeline.LoadResourceAsync(id, cancellationToken), configureAwait: false) + .Select(static resource => resource.Data) + .ObserveOnUIThreadDispatcher() + .Subscribe(this, static (bitmap, self) => self.Image = bitmap) + .AddTo(disposables); + }); } public string Name => _collection.Name; - public Bitmap Image => new(new MemoryStream(_collection.TileImage.ToArray())); + [Reactive] public Bitmap? Image { get; private set; } public string Summary => _collection.Summary; public string Category => string.Join(" \u2022 ", _collection.Tags.Select(t => t.Name)); public int ModCount => _revision.Files.Count; diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionsPage.cs b/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionsPage.cs index fa51b0c682..653d15e2a8 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionsPage.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionsPage.cs @@ -3,7 +3,6 @@ using NexusMods.Abstractions.Loadouts; using NexusMods.Abstractions.Serialization.Attributes; using NexusMods.Abstractions.Settings; -using NexusMods.App.UI.Resources; using NexusMods.App.UI.Windows; using NexusMods.App.UI.WorkspaceSystem; using NexusMods.Icons; @@ -32,8 +31,11 @@ public CollectionsPageFactory(IServiceProvider serviceProvider) : base(servicePr public override ICollectionsViewModel CreateViewModel(CollectionsPageContext context) { - var vm = new CollectionsViewModel(ServiceProvider.GetRequiredService(), - ServiceProvider.GetRequiredService()); + var vm = new CollectionsViewModel( + ServiceProvider, + ServiceProvider.GetRequiredService(), + ServiceProvider.GetRequiredService() + ); return vm; } diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionsViewModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionsViewModel.cs index dc44bf18da..ca2e50697b 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionsViewModel.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/Collections/CollectionsViewModel.cs @@ -18,32 +18,24 @@ namespace NexusMods.App.UI.Pages.LibraryPage.Collections; public class CollectionsViewModel : APageViewModel, ICollectionsViewModel { - private readonly IConnection _conn; - - public CollectionsViewModel(IConnection conn, IWindowManager windowManager) : base(windowManager) + public CollectionsViewModel( + IServiceProvider serviceProvider, + IConnection conn, + IWindowManager windowManager) : base(windowManager) { - _conn = conn; + TabIcon = IconValues.ModLibrary; + TabTitle = "Collections (WIP)"; this.WhenActivated(d => - { - CollectionMetadata.ObserveAll(conn) - .Transform(coll => (ICollectionCardViewModel)new CollectionCardViewModel(conn, coll.Revisions.First().RevisionId)) - .Bind(out _collections) - .Subscribe() - .DisposeWith(d); - } - ); - } - - public IconValue TabIcon { get; } = IconValues.ModLibrary; - public string TabTitle { get; } = "Collections (WIP)"; - public WindowId WindowId { get; set; } - public WorkspaceId WorkspaceId { get; set; } - public PanelId PanelId { get; set; } - public PanelTabId TabId { get; set; } - public bool CanClose() - { - return true; + { + var tileImagePipeline = ImagePipelines.GetCollectionTileImagePipeline(serviceProvider); + + CollectionMetadata.ObserveAll(conn) + .Transform(ICollectionCardViewModel (coll) => new CollectionCardViewModel(tileImagePipeline, conn, coll.Revisions.First().RevisionId)) + .Bind(out _collections) + .Subscribe() + .DisposeWith(d); + }); } private ReadOnlyObservableCollection _collections = new(new ObservableCollection()); diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/Collections/ICollectionCardViewModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/Collections/ICollectionCardViewModel.cs index 235d92e73f..683e87780e 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/Collections/ICollectionCardViewModel.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/Collections/ICollectionCardViewModel.cs @@ -17,7 +17,7 @@ public interface ICollectionCardViewModel : IViewModelInterface /// /// The tile image of the collection. /// - public Bitmap Image { get; } + public Bitmap? Image { get; } /// /// Summary of the collection. diff --git a/src/NexusMods.App.UI/Services.cs b/src/NexusMods.App.UI/Services.cs index ec1c5a405d..a4c123d75c 100644 --- a/src/NexusMods.App.UI/Services.cs +++ b/src/NexusMods.App.UI/Services.cs @@ -269,7 +269,8 @@ public static IServiceCollection AddUI(this IServiceCollection c) .AddSingleton() .AddSingleton() .AddSingleton() - .AddFileSystem(); + .AddFileSystem() + .AddImagePipelines(); } } diff --git a/src/NexusMods.DataModel/NxFileStore.cs b/src/NexusMods.DataModel/NxFileStore.cs index 381f9e0a1d..1ac4dde9ff 100644 --- a/src/NexusMods.DataModel/NxFileStore.cs +++ b/src/NexusMods.DataModel/NxFileStore.cs @@ -112,6 +112,13 @@ public async Task BackupFiles(IEnumerable backups, bool dedup await UpdateIndexes(unpacker, finalPath); } + /// + public Task BackupFiles(string archiveName, IEnumerable files, CancellationToken cancellationToken = default) + { + // TODO: implement with repacking + return BackupFiles(files, deduplicate: true, cancellationToken); + } + private async Task UpdateIndexes(NxUnpacker unpacker, AbsolutePath finalPath) { using var lck = _lock.ReadLock(); diff --git a/src/NexusMods.DataModel/Services.cs b/src/NexusMods.DataModel/Services.cs index 3a4a1ddf5d..acd357102b 100644 --- a/src/NexusMods.DataModel/Services.cs +++ b/src/NexusMods.DataModel/Services.cs @@ -119,7 +119,7 @@ public static IServiceCollection AddDataModel(this IServiceCollection coll) // GC coll.AddAllSingleton(); - coll.AddPersistedResourceModel(); + coll.AddPersistedDbResourceModel(); // Verbs coll.AddLoadoutManagementVerbs() diff --git a/tests/NexusMods.UI.Tests/ImageLoaderTests.cs b/tests/NexusMods.UI.Tests/ImageLoaderTests.cs index b41ecd204a..8dc43df9f4 100644 --- a/tests/NexusMods.UI.Tests/ImageLoaderTests.cs +++ b/tests/NexusMods.UI.Tests/ImageLoaderTests.cs @@ -61,11 +61,10 @@ private IResourceLoader> CreatePipeline() height: 80 )) .Encode(encoderType: EncoderType.Qoi) - .Persist( + .PersistInDb( connection: Connection, referenceAttribute: NexusModsModPageMetadata.ThumbnailResource, - identifierToHash: static tuple => tuple.Item2.ToString().XxHash64AsUtf8(), - identifierToEntityId: static tuple => tuple.Item1, + identifierToHash: static uri => uri.ToString().XxHash64AsUtf8(), partitionId: PartitionId.User(partitionId) ) .Decode(decoderType: DecoderType.Qoi)