From 1e3d7797f05554f5fe2d238f6028042094a21aa4 Mon Sep 17 00:00:00 2001 From: erri120 Date: Wed, 2 Oct 2024 14:39:32 +0200 Subject: [PATCH] Add image pipeline --- .../Loadouts/Harness/DummyFileStore.cs | 5 + .../NexusMods.Abstractions.IO/IFileStore.cs | 6 + .../Models/CollectionMetadata.cs | 23 +++- .../NexusModsModPageMetadata.cs | 2 +- ...stedResource.cs => PersistedDbResource.cs} | 17 ++- ...Loader.cs => PersistedDbResourceLoader.cs} | 106 +++++++++--------- .../PersistedFileResourceLoader.cs | 92 +++++++++++++++ .../NexusModsLibrary.cs | 26 ++--- .../Assets/collection-tile-fallback.png | Bin 0 -> 4118 bytes src/NexusMods.App.UI/ImagePipelines.cs | 93 +++++++++++++++ src/NexusMods.App.UI/NexusMods.App.UI.csproj | 4 + .../CollectionCardDesignViewModel.cs | 2 +- .../Collections/CollectionCardViewModel.cs | 27 ++++- .../Collections/CollectionsPage.cs | 8 +- .../Collections/CollectionsViewModel.cs | 38 +++---- .../Collections/ICollectionCardViewModel.cs | 2 +- src/NexusMods.App.UI/Services.cs | 3 +- src/NexusMods.DataModel/NxFileStore.cs | 7 ++ src/NexusMods.DataModel/Services.cs | 2 +- tests/NexusMods.UI.Tests/ImageLoaderTests.cs | 5 +- 20 files changed, 357 insertions(+), 111 deletions(-) rename src/Abstractions/NexusMods.Abstractions.Resources.DB/{PersistedResource.cs => PersistedDbResource.cs} (60%) rename src/Abstractions/NexusMods.Abstractions.Resources.DB/{PersistedResourceLoader.cs => PersistedDbResourceLoader.cs} (57%) create mode 100644 src/Abstractions/NexusMods.Abstractions.Resources.IO/PersistedFileResourceLoader.cs create mode 100644 src/NexusMods.App.UI/Assets/collection-tile-fallback.png create mode 100644 src/NexusMods.App.UI/ImagePipelines.cs 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 0000000000000000000000000000000000000000..73e9bb3150b8a655cb22e96358f710bf20718e0b GIT binary patch literal 4118 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 q=rrptOC6}QuSj0>#qH(Lm9s(n*1iU~>( + 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)