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);