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.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 dde316208c..896c18918f 100644 --- a/src/NexusMods.App.UI/Pages/CollectionDownload/CollectionDownloadViewModel.cs +++ b/src/NexusMods.App.UI/Pages/CollectionDownload/CollectionDownloadViewModel.cs @@ -4,19 +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.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; @@ -28,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); @@ -97,8 +88,8 @@ public CollectionDownloadViewModel( onNextAsync: (message, cancellationToken) => { return message.Item.Match( - f0: x => DownloadOrOpenPage(x, cancellationToken), - f1: x => DownloadExternalItem(x, cancellationToken) + f0: x => _collectionDownloader.Download(x, cancellationToken), + f1: x => _collectionDownloader.Download(x, cancellationToken) ); }, awaitOperation: AwaitOperation.Parallel, @@ -107,26 +98,6 @@ public CollectionDownloadViewModel( }); } - private async ValueTask DownloadExternalItem(ExternalItem externalItem, CancellationToken cancellationToken) - { - // TODO: - await Task.Yield(); - } - - 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 index d472bc599f..6e53de70ec 100644 --- a/src/NexusMods.App.UI/Pages/CollectionDownload/ExternalDownloadItemModel.cs +++ b/src/NexusMods.App.UI/Pages/CollectionDownload/ExternalDownloadItemModel.cs @@ -16,17 +16,21 @@ public class ExternalDownloadItemModel : TreeDataGridItemModel { - self.IsInLibraryObservable.ObserveOnUIThreadDispatcher() - .Subscribe(self, static (inLibrary, self) => + 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) => { - self.DownloadState.Value = inLibrary ? JobStatus.Completed : JobStatus.None; + var (inLibrary, status) = tuple; + self.DownloadState.Value = inLibrary ? JobStatus.Completed : status; self.DownloadButtonText.Value = ILibraryItemWithDownloadAction.GetButtonText(status: self.DownloadState.Value); }).AddTo(disposables); }); @@ -43,7 +47,7 @@ public ExternalDownloadItemModel(CollectionDownloadExternal.ReadOnly externalDow } public required Observable IsInLibraryObservable { get; init; } - // public required Observable DownloadJobObservable { get; init; } + public required Observable DownloadJobObservable { get; init; } public BindableReactiveProperty Name { get; } = new(value: "-"); diff --git a/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithAction.cs b/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithAction.cs index c987f4ed38..2ac76d28de 100644 --- a/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithAction.cs +++ b/src/NexusMods.App.UI/Pages/LibraryPage/ILibraryItemWithAction.cs @@ -2,6 +2,7 @@ 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; @@ -68,11 +69,9 @@ public static string GetButtonText(int numInstalled, int numTotal, bool isExpand } } -public record struct ExternalItem(Uri Uri, Size Size, Md5HashValue Md5); - -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 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 = downloadJobObservable, }; model.Name.Value = externalDownload.AsCollectionDownload().Name; 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);