diff --git a/src/NexusMods.Collections/DirectDownloadJob.cs b/src/Networking/NexusMods.Networking.NexusWebApi/ExternalDownloadJob.cs similarity index 80% rename from src/NexusMods.Collections/DirectDownloadJob.cs rename to src/Networking/NexusMods.Networking.NexusWebApi/ExternalDownloadJob.cs index 3bf80e1739..cb68565dc4 100644 --- a/src/NexusMods.Collections/DirectDownloadJob.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi/ExternalDownloadJob.cs @@ -1,9 +1,7 @@ -using System.Runtime.InteropServices; using System.Security.Cryptography; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NexusMods.Abstractions.Collections; -using NexusMods.Abstractions.Collections.Types; using NexusMods.Abstractions.Jobs; using NexusMods.Abstractions.Library.Models; using NexusMods.Abstractions.MnemonicDB.Attributes; @@ -11,40 +9,43 @@ using NexusMods.Networking.HttpDownloader; using NexusMods.Paths; -namespace NexusMods.Collections; +namespace NexusMods.Networking.NexusWebApi; -public record DirectDownloadJob : HttpDownloadJob +/// +/// Job for external collection downloads. +/// +public record ExternalDownloadJob : HttpDownloadJob { /// /// The expected MD5 hash value of the downloaded file. /// public required Md5HashValue ExpectedMd5 { get; init; } - + /// /// The user-friendly name of the file. /// public required string LogicalFileName { get; init; } - + /// /// Create a new download job for the given URL, the job will fail if the downloaded file does not /// match the expected MD5 hash. /// - public static IJobTask Create(IServiceProvider provider, Uri uri, + public static IJobTask Create(IServiceProvider provider, Uri uri, Md5HashValue expectedMd5, string logicalFileName) { var monitor = provider.GetRequiredService(); var tempFileManager = provider.GetRequiredService(); - var job = new DirectDownloadJob + var job = new ExternalDownloadJob { - Logger = provider.GetRequiredService>(), + Logger = provider.GetRequiredService>(), ExpectedMd5 = expectedMd5, LogicalFileName = logicalFileName, DownloadPageUri = uri, Destination = tempFileManager.CreateFile(), Uri = uri, }; - - return monitor.Begin(job); + + return monitor.Begin(job); } @@ -59,7 +60,7 @@ public override async ValueTask AddMetadata(ITransaction tx, LibraryFile.New lib if (md5Actual != ExpectedMd5) throw new InvalidOperationException($"MD5 hash mismatch. Expected: {ExpectedMd5}, Actual: {md5Actual}"); } - + tx.Add(libraryFile, DirectDownloadLibraryFile.Md5, ExpectedMd5); tx.Add(libraryFile, DirectDownloadLibraryFile.LogicalFileName, LogicalFileName); } diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.Collections.cs b/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.Collections.cs index 679dcd0454..17be315a30 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.Collections.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.Collections.cs @@ -44,7 +44,7 @@ public partial class NexusModsLibrary ); var collectionRevisionInfo = apiResult.Data?.CollectionRevision; - if (collectionRevisionInfo is null) throw new NotSupportedException($"API call returned no data for `{slug}` `{revisionNumber}`"); + if (collectionRevisionInfo is null) throw new NotSupportedException($"API call returned no data for collection slug `{slug}` revision `{revisionNumber}`"); using var tx = _connection.BeginTransaction(); var db = _connection.Db; diff --git a/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.cs b/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.cs index 1f9627c88c..b3bb2f25f8 100644 --- a/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.cs +++ b/src/Networking/NexusMods.Networking.NexusWebApi/NexusModsLibrary.cs @@ -187,11 +187,11 @@ public async Task> CreateDownloadJo } public async Task> CreateDownloadJob( + AbsolutePath destination, NexusModsFileMetadata.ReadOnly fileMetadata, CancellationToken cancellationToken = default) { - await using var tempPath = _temporaryFileManager.CreateFile(); - return await CreateDownloadJob(tempPath, fileMetadata.Uid.GameId, fileMetadata.ModPage.Uid.ModId, fileMetadata.Uid.FileId, cancellationToken: cancellationToken); + return await CreateDownloadJob(destination, fileMetadata.Uid.GameId, fileMetadata.ModPage.Uid.ModId, fileMetadata.Uid.FileId, cancellationToken: cancellationToken); } /// diff --git a/src/NexusMods.App.UI/NexusMods.App.UI.csproj b/src/NexusMods.App.UI/NexusMods.App.UI.csproj index 1b2d8a7a40..6cf209f8da 100644 --- a/src/NexusMods.App.UI/NexusMods.App.UI.csproj +++ b/src/NexusMods.App.UI/NexusMods.App.UI.csproj @@ -97,6 +97,7 @@ + diff --git a/src/NexusMods.App.UI/Pages/CollectionDownload/CollectionDownloadViewModel.cs b/src/NexusMods.App.UI/Pages/CollectionDownload/CollectionDownloadViewModel.cs index 0c03518b95..896c18918f 100644 --- a/src/NexusMods.App.UI/Pages/CollectionDownload/CollectionDownloadViewModel.cs +++ b/src/NexusMods.App.UI/Pages/CollectionDownload/CollectionDownloadViewModel.cs @@ -4,21 +4,15 @@ using DynamicData; using Microsoft.Extensions.DependencyInjection; using NexusMods.Abstractions.Jobs; -using NexusMods.Abstractions.Library; -using NexusMods.Abstractions.NexusModsLibrary; using NexusMods.Abstractions.NexusModsLibrary.Models; -using NexusMods.Abstractions.NexusWebApi; using NexusMods.Abstractions.NexusWebApi.Types; -using NexusMods.Abstractions.Resources; -using NexusMods.Abstractions.Telemetry; using NexusMods.App.UI.Controls; using NexusMods.App.UI.Extensions; using NexusMods.App.UI.Pages.LibraryPage; using NexusMods.App.UI.Windows; using NexusMods.App.UI.WorkspaceSystem; -using NexusMods.CrossPlatform.Process; +using NexusMods.Collections; using NexusMods.MnemonicDB.Abstractions; -using NexusMods.Networking.NexusWebApi; using NexusMods.Paths; using R3; using ReactiveUI; @@ -30,12 +24,10 @@ public class CollectionDownloadViewModel : APageViewModel(); - _nexusModsLibrary = serviceProvider.GetRequiredService(); - _libraryService = serviceProvider.GetRequiredService(); - _temporaryFileManager = serviceProvider.GetRequiredService(); - _osInterop = serviceProvider.GetRequiredService(); - _loginManager = serviceProvider.GetRequiredService(); + _collectionDownloader = new CollectionDownloader(_serviceProvider); var tileImagePipeline = ImagePipelines.GetCollectionTileImagePipeline(serviceProvider); var backgroundImagePipeline = ImagePipelines.GetCollectionBackgroundImagePipeline(serviceProvider); @@ -96,27 +85,19 @@ public CollectionDownloadViewModel( .AddTo(disposables); TreeDataGridAdapter.MessageSubject.SubscribeAwait( - onNextAsync: (message, cancellationToken) => DownloadOrOpenPage(message.Item.AsT0, cancellationToken), + onNextAsync: (message, cancellationToken) => + { + return message.Item.Match( + f0: x => _collectionDownloader.Download(x, cancellationToken), + f1: x => _collectionDownloader.Download(x, cancellationToken) + ); + }, awaitOperation: AwaitOperation.Parallel, configureAwait: false ).AddTo(disposables); }); } - private async ValueTask DownloadOrOpenPage(NexusModsFileMetadata.ReadOnly fileMetadata, CancellationToken cancellationToken) - { - if (_loginManager.IsPremium) - { - await using var tempPath = _temporaryFileManager.CreateFile(); - var job = await _nexusModsLibrary.CreateDownloadJob(tempPath, fileMetadata.Uid.GameId, fileMetadata.ModPage.Uid.ModId, fileMetadata.Uid.FileId, cancellationToken: cancellationToken); - await _libraryService.AddDownload(job); - } - else - { - await _osInterop.OpenUrl(fileMetadata.GetUri(), logOutput: false, fireAndForget: true, cancellationToken: cancellationToken); - } - } - public string Name => _collection.Name; public string Summary => _collection.Summary; public int ModCount => _revision.Downloads.Count; diff --git a/src/NexusMods.App.UI/Pages/CollectionDownload/ExternalDownloadItemModel.cs b/src/NexusMods.App.UI/Pages/CollectionDownload/ExternalDownloadItemModel.cs new file mode 100644 index 0000000000..6e53de70ec --- /dev/null +++ b/src/NexusMods.App.UI/Pages/CollectionDownload/ExternalDownloadItemModel.cs @@ -0,0 +1,84 @@ +using NexusMods.Abstractions.Jobs; +using NexusMods.Abstractions.NexusModsLibrary.Models; +using NexusMods.App.UI.Controls; +using NexusMods.App.UI.Extensions; +using NexusMods.App.UI.Pages.LibraryPage; +using NexusMods.MnemonicDB.Abstractions; +using NexusMods.Paths; +using R3; + +namespace NexusMods.App.UI.Pages.CollectionDownload; + +public class ExternalDownloadItemModel : TreeDataGridItemModel, + ILibraryItemWithName, + ILibraryItemWithSize, + ILibraryItemWithDownloadAction +{ + public ExternalDownloadItemModel(CollectionDownloadExternal.ReadOnly externalDownload) + { + DownloadableItem = new DownloadableItem(externalDownload); + FormattedSize = ItemSize.ToFormattedProperty(); + DownloadItemCommand = ILibraryItemWithDownloadAction.CreateCommand(this); + + // ReSharper disable once NotDisposedResource + var modelActivationDisposable = this.WhenActivated(static (self, disposables) => + { + self.IsInLibraryObservable.CombineLatest( + source2: self.DownloadJobObservable.SelectMany(job => job.ObservableStatus.ToObservable()).Prepend(JobStatus.None), + resultSelector: static (a, b) => (a, b)) + .ObserveOnUIThreadDispatcher() + .Subscribe(self, static (tuple, self) => + { + var (inLibrary, status) = tuple; + self.DownloadState.Value = inLibrary ? JobStatus.Completed : status; + self.DownloadButtonText.Value = ILibraryItemWithDownloadAction.GetButtonText(status: self.DownloadState.Value); + }).AddTo(disposables); + }); + + _modelDisposable = Disposable.Combine( + modelActivationDisposable, + Name, + ItemSize, + FormattedSize, + DownloadItemCommand, + DownloadState, + DownloadButtonText + ); + } + + public required Observable IsInLibraryObservable { get; init; } + public required Observable DownloadJobObservable { get; init; } + + public BindableReactiveProperty Name { get; } = new(value: "-"); + + public ReactiveProperty ItemSize { get; } = new(); + public BindableReactiveProperty FormattedSize { get; } + + public DownloadableItem DownloadableItem { get; } + + public ReactiveCommand DownloadItemCommand { get; } + + public BindableReactiveProperty DownloadState { get; } = new(); + + public BindableReactiveProperty DownloadButtonText { get; } = new(value: ILibraryItemWithDownloadAction.GetButtonText(status: JobStatus.None)); + + private bool _isDisposed; + private readonly IDisposable _modelDisposable; + + protected override void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + _modelDisposable.Dispose(); + } + + _isDisposed = true; + } + + base.Dispose(disposing); + } + + public override string ToString() => $"External Download: {Name.Value}"; +} diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithAction.cs b/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithAction.cs index 336b6fdd63..2ac76d28de 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithAction.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithAction.cs @@ -1,7 +1,10 @@ using System.Diagnostics.CodeAnalysis; using NexusMods.Abstractions.Jobs; +using NexusMods.Abstractions.MnemonicDB.Attributes; using NexusMods.Abstractions.NexusModsLibrary; +using NexusMods.Abstractions.NexusModsLibrary.Models; using NexusMods.App.UI.Controls; +using NexusMods.Paths; using OneOf; using R3; @@ -66,9 +69,9 @@ public static string GetButtonText(int numInstalled, int numTotal, bool isExpand } } -public class DownloadableItem : OneOfBase +public class DownloadableItem : OneOfBase { - public DownloadableItem(OneOf input) : base(input) { } + public DownloadableItem(OneOf input) : base(input) { } } public interface ILibraryItemWithDownloadAction : ILibraryItemWithAction diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/NexusModsFileMetadataLibraryItemModel.cs b/src/NexusMods.App.UI/Pages/LibraryPage/NexusModsFileMetadataLibraryItemModel.cs index 63e69fa307..ebce9c4386 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/NexusModsFileMetadataLibraryItemModel.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/NexusModsFileMetadataLibraryItemModel.cs @@ -1,5 +1,5 @@ using NexusMods.Abstractions.Jobs; -using NexusMods.Abstractions.NexusModsLibrary; +using NexusMods.Abstractions.NexusModsLibrary.Models; using NexusMods.App.UI.Controls; using NexusMods.App.UI.Extensions; using NexusMods.MnemonicDB.Abstractions; @@ -14,9 +14,9 @@ public class NexusModsFileMetadataLibraryItemModel : TreeDataGridItemModel> ObserveCollectionIte .ObserveDatoms(CollectionDownloadEntity.CollectionRevision, revisionMetadata) .AsEntityIds() .Transform(datom => CollectionDownloadEntity.Load(_connection.Db, datom.E)) - .Filter(static downloadEntity => downloadEntity.IsCollectionDownloadNexusMods())// TODO: || downloadEntity.IsCollectionDownloadExternal()) + .Filter(static downloadEntity => downloadEntity.IsCollectionDownloadNexusMods() || downloadEntity.IsCollectionDownloadExternal()) .Transform(ILibraryItemModel (downloadEntity) => { if (downloadEntity.TryGetAsCollectionDownloadNexusMods(out var nexusModsDownload)) @@ -98,7 +100,7 @@ private ILibraryItemModel ToLibraryItemModel(CollectionDownloadNexusMods.ReadOnl }) .WhereNotNull(); - var model = new NexusModsFileMetadataLibraryItemModel(nexusModsDownload.FileMetadata) + var model = new NexusModsFileMetadataLibraryItemModel(nexusModsDownload) { IsInLibraryObservable = isInLibraryObservable, DownloadJobObservable = downloadJobObservable, @@ -106,14 +108,52 @@ private ILibraryItemModel ToLibraryItemModel(CollectionDownloadNexusMods.ReadOnl model.Name.Value = nexusModsDownload.FileMetadata.Name; model.Version.Value = nexusModsDownload.FileMetadata.Version; - - if (NexusModsFileMetadata.Size.TryGet(nexusModsDownload.FileMetadata, out var size)) model.ItemSize.Value = size; + model.ItemSize.Value = nexusModsDownload.FileMetadata.Size; return model; } private ILibraryItemModel ToLibraryItemModel(CollectionDownloadExternal.ReadOnly externalDownload) { - throw new NotImplementedException(); + var isInLibraryObservable = _connection + .ObserveDatoms(DirectDownloadLibraryFile.Md5) + .Transform(datom => DirectDownloadLibraryFile.Md5.ReadValue(datom.ValueSpan, datom.Prefix.ValueTag, _connection.AttributeResolver)) + .Filter(hash => hash == externalDownload.Md5) + .IsNotEmpty() + .ToObservable() + .Prepend((_connection, externalDownload.Md5), static state => + { + var (connection, hash) = state; + var libraryItems = DirectDownloadLibraryFile.FindByMd5(connection.Db, hash); + return libraryItems.Count > 0; + }); + + var downloadJobObservable = _jobMonitor.GetObservableChangeSet() + .Filter(job => + { + var definition = job.Definition as ExternalDownloadJob; + Debug.Assert(definition is not null); + return definition.ExpectedMd5 == externalDownload.Md5; + }) + .QueryWhenChanged(static query => query.Items.MaxBy(job => job.Status)) + .ToObservable() + .Prepend((_jobMonitor, externalDownload), static state => + { + var (jobMonitor, download) = state; + if (jobMonitor.Jobs.TryGetFirst(job => job.Definition is ExternalDownloadJob externalDownloadJob && externalDownloadJob.ExpectedMd5 == download.Md5, out var job)) + return job; + return null; + }) + .WhereNotNull(); + + var model = new ExternalDownloadItemModel(externalDownload) + { + IsInLibraryObservable = isInLibraryObservable, + DownloadJobObservable = downloadJobObservable, + }; + + model.Name.Value = externalDownload.AsCollectionDownload().Name; + model.ItemSize.Value = externalDownload.Size; + return model; } public IObservable> ObserveFlatLibraryItems(LibraryFilter libraryFilter) diff --git a/src/NexusMods.Collections/CollectionDownloader.cs b/src/NexusMods.Collections/CollectionDownloader.cs new file mode 100644 index 0000000000..51338c0cd6 --- /dev/null +++ b/src/NexusMods.Collections/CollectionDownloader.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NexusMods.Abstractions.Library; +using NexusMods.Abstractions.NexusModsLibrary.Models; +using NexusMods.Abstractions.NexusWebApi; +using NexusMods.CrossPlatform.Process; +using NexusMods.Networking.NexusWebApi; +using NexusMods.Paths; + +namespace NexusMods.Collections; + +/// +/// Methods for collection downloads. +/// +public class CollectionDownloader +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly ILoginManager _loginManager; + private readonly TemporaryFileManager _temporaryFileManager; + private readonly NexusModsLibrary _nexusModsLibrary; + private readonly ILibraryService _libraryService; + private readonly IOSInterop _osInterop; + private readonly HttpClient _httpClient; + + /// + /// Constructor. + /// + public CollectionDownloader(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + _logger = serviceProvider.GetRequiredService>(); + _loginManager = serviceProvider.GetRequiredService(); + _temporaryFileManager = serviceProvider.GetRequiredService(); + _nexusModsLibrary = serviceProvider.GetRequiredService(); + _libraryService = serviceProvider.GetRequiredService(); + _osInterop = serviceProvider.GetRequiredService(); + _httpClient = serviceProvider.GetRequiredService(); + } + + private async ValueTask CanDirectDownload(CollectionDownloadExternal.ReadOnly download, CancellationToken cancellationToken) + { + _logger.LogDebug("Testing if `{Uri}` can be downloaded directly", download.Uri); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Head, download.Uri); + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) return false; + + var contentType = response.Content.Headers.ContentType?.MediaType; + if (contentType is null || !contentType.StartsWith("application/")) + { + _logger.LogInformation("Download at `{Uri}` can't be downloaded automatically because Content-Type `{ContentType}` doesn't indicate a binary download", download.Uri, contentType); + return false; + } + + if (!response.Content.Headers.ContentLength.HasValue) + { + _logger.LogInformation("Download at `{Uri}` can't be downloaded automatically because the response doesn't have a Content-Length", download.Uri); + return false; + } + + var size = Size.FromLong(response.Content.Headers.ContentLength.Value); + if (size != download.Size) + { + _logger.LogWarning("Download at `{Uri}` can't be downloaded automatically because the Content-Length `{ContentLength}` doesn't match the expected size `{ExpectedSize}`", download.Uri, size, download.Size); + return false; + } + + return true; + } + catch (Exception e) + { + _logger.LogError(e, "Exception while checking if `{Uri}` can be downloaded directly", download.Uri); + return false; + } + } + + /// + /// Downloads an external file or opens the browser if the file can't be downloaded automatically. + /// + public async ValueTask Download(CollectionDownloadExternal.ReadOnly download, CancellationToken cancellationToken) + { + if (await CanDirectDownload(download, cancellationToken)) + { + _logger.LogInformation("Downloading external file at `{Uri}` directly", download.Uri); + var job = ExternalDownloadJob.Create(_serviceProvider, download.Uri, download.Md5, download.AsCollectionDownload().Name); + await _libraryService.AddDownload(job); + } + else + { + _logger.LogInformation("Unable to direct download `{Uri}`, using browse as a fallback", download.Uri); + await _osInterop.OpenUrl(download.Uri, logOutput: false, fireAndForget: true, cancellationToken: cancellationToken); + } + } + + /// + /// Downloads a file from nexus mods for premium users or opens the download page in the browser. + /// + public async ValueTask Download(CollectionDownloadNexusMods.ReadOnly download, CancellationToken cancellationToken) + { + if (_loginManager.IsPremium) + { + await using var tempPath = _temporaryFileManager.CreateFile(); + var job = await _nexusModsLibrary.CreateDownloadJob(tempPath, download.FileMetadata, cancellationToken: cancellationToken); + await _libraryService.AddDownload(job); + } + else + { + await _osInterop.OpenUrl(download.FileMetadata.GetUri(), logOutput: false, fireAndForget: true, cancellationToken: cancellationToken); + } + } +} diff --git a/src/NexusMods.Collections/InstallCollectionJob.cs b/src/NexusMods.Collections/InstallCollectionJob.cs index f1c2cdb8aa..0c339f54ab 100644 --- a/src/NexusMods.Collections/InstallCollectionJob.cs +++ b/src/NexusMods.Collections/InstallCollectionJob.cs @@ -465,7 +465,7 @@ private async Task EnsureDirectMod(Mod mod) await using var tempPath = TemporaryFileManager.CreateFile(); - var job = DirectDownloadJob.Create(SerivceProvider, mod.Source.Url!, mod.Source.Md5, mod.Name); + var job = ExternalDownloadJob.Create(SerivceProvider, mod.Source.Url!, mod.Source.Md5, mod.Name); var libraryFile = await LibraryService.AddDownload(job); return (mod, libraryFile);