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