Skip to content

Commit

Permalink
Implement Download Service/Tracking, Progress UI, Download Cancel UI …
Browse files Browse the repository at this point in the history
…& Misc. UI Fixes (#360)

* Added: System for Calculating Job Throughput

* Added: Additional Rate Limiter States & Collective Throughput

* Added: Progress Tracking for Downloads

* WIP: DownloadService

* Fixed: Bad Code Written during PR Feedback

* Added: Tests for DownloadService and NXM+HTTP Implementation

* Added Download Bar Style for Download List

* Updated: LoginManager Dependencies

* Changed: Fixed remaining code after Rebase from Main

* Initial Progress on 'In Progress' UI

* Moved: Downloaders Code to Separate Project

* [WIP] Some Progress on InProgress UI

* Added: Post Merge Fixes

* Added: Functional Download Status Bar Column

* Added: Pause/Resume Column Type

* Added: Finalized LayoutGrid Buttons & Moved Grid Columns out for Tidyness

* Completed: The Visual Design of InProgress DataGrid

* Added: Auto Tint to Blue on InProgress UI Elements When Download Active

* Changed: Remove Duplicate Code between Design and Runtime ViewModels

* Wire up the Remainder of the InProgress Menu UI Updates

* Added: Show Progress in Bar

* Fixed: Wrong binding order.

* Fixed: Incorrect Structural Border Colour (Compared to Branding on FIGMA)

* Added: H6 Montserrat & Fixed Incorrect Text Colour on Body2RobotoRegular

* Added: Ok/Cancel MessageBox

* Added: Cancel Download Overlay

* Fixed: Typo in CancelDownloadOverlayView

* Fixed: Incorrectly swapped button hover and pressed styles.

* temp commit please ignore [travelling soon, need to transfer to laptop]

* Wire up Actual Data with InProgress UI

* Wire up most of the remainder of the UI

* Added: Polling for DownloadTaskViewModel

* Fixed: Performance Issue with FoundGamesView Control

* Removed: Dead TODO

* Moved: Login Overlay & Added new Queue Based Overlay System

* Changed: Use `BindToClasses` helper method.

* Added: Tests for InProgressView & Accompanying Bug Fixes

* Fixed: Non-Property SetOverlayItem caused Binding Failures

* Fixed: Post Merge usings

* Added: Tests for Ok/Cancel Modal

* Added: Basic tests for DownloadTaskViewModel

* Added: Missing Lifetime for Download Tasks

* Added: Missing Thread Specifiers

* Fixed: `DefaultModName` being Mistakenly Ignored after DataStore Fix

* Added: Missing Selected Item Binding & Fix Cancel in Actual App

* Fixed: Missing TaskCompletionSource to dismiss UI element.

* Fixed: Deadlock in (Http/Nxm)DownloadTask.

While waiting with Async is a possibility, I chose to remove the wait entirely, as to keep UI responsiveness high (these are called from UI).

There is no side effect to this; as no resources (e.g. file handles) are shared with the outside.

* Changed: StackPanel to Grid to Fix DataGrid Size Issue

* Added: Existing In Progress Downloads upon initialisation of Downloads ViewModel

* Remove Dead Object & Comment Deliberate Choice of Constraint

* Changed: Refactor out common grid components and separate loadout/download grid by enum.

* Use Humanizer in the display of byte sizes

* Changed: Adjusted column widths to accommodate altered version column size.

---------

Co-authored-by: Timothy Baldridge <tbaldridge@gmail.com>
  • Loading branch information
Sewer56 and halgari authored Jul 20, 2023
1 parent 7488b2a commit f4ac863
Show file tree
Hide file tree
Showing 164 changed files with 4,226 additions and 383 deletions.
14 changes: 14 additions & 0 deletions NexusMods.App.sln
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Common.Tests", "t
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Networking.NexusWebApi.NMA", "src\Networking\NexusMods.Networking.NexusWebApi.NMA\NexusMods.Networking.NexusWebApi.NMA.csproj", "{871E2565-BD95-43D1-9EC3-CAAC74D55507}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Networking.Downloaders", "src\Networking\NexusMods.Networking.Downloaders\NexusMods.Networking.Downloaders.csproj", "{3FBDEE15-9892-40EF-9593-6353068FAF48}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Networking.Downloaders.Tests", "tests\Networking\NexusMods.Networking.Downloaders.Tests\NexusMods.Networking.Downloaders.Tests.csproj", "{09B037AB-07BB-4154-95FD-6EA2E55C4568}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -300,6 +304,14 @@ Global
{871E2565-BD95-43D1-9EC3-CAAC74D55507}.Debug|Any CPU.Build.0 = Debug|Any CPU
{871E2565-BD95-43D1-9EC3-CAAC74D55507}.Release|Any CPU.ActiveCfg = Release|Any CPU
{871E2565-BD95-43D1-9EC3-CAAC74D55507}.Release|Any CPU.Build.0 = Release|Any CPU
{3FBDEE15-9892-40EF-9593-6353068FAF48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3FBDEE15-9892-40EF-9593-6353068FAF48}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3FBDEE15-9892-40EF-9593-6353068FAF48}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3FBDEE15-9892-40EF-9593-6353068FAF48}.Release|Any CPU.Build.0 = Release|Any CPU
{09B037AB-07BB-4154-95FD-6EA2E55C4568}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{09B037AB-07BB-4154-95FD-6EA2E55C4568}.Debug|Any CPU.Build.0 = Debug|Any CPU
{09B037AB-07BB-4154-95FD-6EA2E55C4568}.Release|Any CPU.ActiveCfg = Release|Any CPU
{09B037AB-07BB-4154-95FD-6EA2E55C4568}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -353,6 +365,8 @@ Global
{CB61A764-B3BB-42C0-8CDB-DBE57FB80DF5} = {CF7454A5-0EBB-46E7-9A10-614380DB95D9}
{FE0B804A-949E-44E7-9531-B16664ACEC01} = {52AF9D62-7D5B-4AD0-BA12-86F2AA67428B}
{871E2565-BD95-43D1-9EC3-CAAC74D55507} = {D7E9D8F5-8AC8-4ADA-B219-C549084AD84C}
{3FBDEE15-9892-40EF-9593-6353068FAF48} = {D7E9D8F5-8AC8-4ADA-B219-C549084AD84C}
{09B037AB-07BB-4154-95FD-6EA2E55C4568} = {897C4198-884F-448A-B0B0-C2A6D971EAE0}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {9F9F8352-34DD-42C0-8564-EE9AF34A3501}
Expand Down
94 changes: 94 additions & 0 deletions src/Networking/NexusMods.Networking.Downloaders/DownloadService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using System.Reactive.Subjects;
using Microsoft.Extensions.DependencyInjection;
using NexusMods.DataModel.Loadouts;
using NexusMods.DataModel.RateLimiting;
using NexusMods.Networking.Downloaders.Interfaces;
using NexusMods.Networking.NexusWebApi.Types;
using NexusMods.Paths;

namespace NexusMods.Networking.Downloaders;

/// <inheritdoc />
public class DownloadService : IDownloadService
{
/// <inheritdoc />
public List<IDownloadTask> Downloads { get; } = new();

private readonly IServiceProvider _provider;
private readonly Subject<IDownloadTask> _started = new();
private readonly Subject<IDownloadTask> _completed = new();
private readonly Subject<IDownloadTask> _cancelled = new();
private readonly Subject<IDownloadTask> _paused = new();
private readonly Subject<IDownloadTask> _resumed = new();

public DownloadService(IServiceProvider provider)
{
_provider = provider;
}

/// <inheritdoc />
public IObservable<IDownloadTask> StartedTasks => _started;

/// <inheritdoc />
public IObservable<IDownloadTask> CompletedTasks => _completed;

/// <inheritdoc />
public IObservable<IDownloadTask> CancelledTasks => _cancelled;

/// <inheritdoc />
public IObservable<IDownloadTask> PausedTasks => _paused;

/// <inheritdoc />
public IObservable<IDownloadTask> ResumedTasks => _resumed;

/// <inheritdoc />
public void AddNxmTask(NXMUrl url)
{
var task = _provider.GetRequiredService<NxmDownloadTask>();
task.Init(url);
AddTask(task);
}

/// <inheritdoc />
public void AddHttpTask(string url, Loadout loadout)
{
var task = _provider.GetRequiredService<HttpDownloadTask>();
task.Init(url, loadout);
AddTask(task);
}

/// <inheritdoc />
public void AddTask(IDownloadTask task)
{
Downloads.Add(task);
_ = task.StartAsync();
_started.OnNext(task);
}

/// <inheritdoc />
public void OnComplete(IDownloadTask task)
{
_completed.OnNext(task);
}

/// <inheritdoc />
public void OnCancelled(IDownloadTask task)
{
_cancelled.OnNext(task);
}

/// <inheritdoc />
public void OnPaused(IDownloadTask task)
{
_paused.OnNext(task);
}

/// <inheritdoc />
public void OnResumed(IDownloadTask task)
{
_resumed.OnNext(task);
}

/// <inheritdoc />
public Size GetThroughput() => Downloads.SelectMany(x => x.DownloadJobs).GetTotalThroughput(new DateTimeProvider());
}
138 changes: 138 additions & 0 deletions src/Networking/NexusMods.Networking.Downloaders/HttpDownloadTask.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using Microsoft.Extensions.Logging;
using NexusMods.DataModel.Abstractions;
using NexusMods.DataModel.Loadouts;
using NexusMods.DataModel.RateLimiting;
using NexusMods.Networking.Downloaders.Interfaces;
using NexusMods.Networking.Downloaders.Interfaces.Traits;
using NexusMods.Networking.HttpDownloader;
using NexusMods.Paths;

namespace NexusMods.Networking.Downloaders;

/// <summary>
/// Represents an individual task to download and install a .nxm link.
/// </summary>
/// <remarks>
/// This task is usually created via <see cref="DownloadService.AddNxmTask"/>.
/// </remarks>
public class HttpDownloadTask : IDownloadTask, IHaveFileSize
{
private string _url = null!;
private readonly ILogger<HttpDownloadTask> _logger;
private readonly TemporaryFileManager _temp;
private readonly HttpClient _client;
private readonly IHttpDownloader _downloader;
private readonly IArchiveAnalyzer _archiveAnalyzer;
private readonly IArchiveInstaller _archiveInstaller;
private readonly CancellationTokenSource _tokenSource;
private readonly HttpDownloaderState _state;
private Loadout? _loadout;
private Task? _task;

/// <inheritdoc />
public IEnumerable<IJob<Size>> DownloadJobs => _state.Jobs;

/// <inheritdoc />
public DownloadService Owner { get; }

/// <inheritdoc />
public DownloadTaskStatus Status { get; private set; } = DownloadTaskStatus.Idle;

/// <inheritdoc />
public string FriendlyName { get; private set; } = "Unknown HTTP Download";

/// <inheritdoc />
public long SizeBytes { get; private set; } = -1;

/// <summary/>
/// <remarks>
/// This constructor is intended to be called from Dependency Injector.
/// After running this constructor, you will need to run
/// </remarks>
public HttpDownloadTask(ILogger<HttpDownloadTask> logger, TemporaryFileManager temp, HttpClient client, IHttpDownloader downloader, IArchiveAnalyzer archiveAnalyzer, IArchiveInstaller archiveInstaller, DownloadService owner)
{
_logger = logger;
_temp = temp;
_client = client;
_downloader = downloader;
_archiveAnalyzer = archiveAnalyzer;
_archiveInstaller = archiveInstaller;
_tokenSource = new CancellationTokenSource();
_state = new HttpDownloaderState();
Owner = owner;
}

/// <summary>
/// Initializes components of this task that cannot be DI Injected.
/// </summary>
public void Init(string url, Loadout loadout)
{
_url = url;
_loadout = loadout;
}

public Task StartAsync()
{
_task = StartImpl();
return _task;
}

private async Task StartImpl()
{
var token = _tokenSource.Token;
await using var tempPath = _temp.CreateFile();

Status = DownloadTaskStatus.Downloading;
var nameSize = await GetNameAndSize();
FriendlyName = nameSize.FileName;
SizeBytes = nameSize.FileSize;
var request = new HttpRequestMessage(HttpMethod.Get, _url);
await _downloader.DownloadAsync(new[] { request }, tempPath, _state, Size.FromLong(SizeBytes <= 0 ? 0 : SizeBytes), token);

Status = DownloadTaskStatus.Installing;
var analyzed = await _archiveAnalyzer.AnalyzeFileAsync(tempPath, token:token);
await _archiveInstaller.AddMods(_loadout!.LoadoutId, analyzed.Hash, nameSize.FileName, token);

Status = DownloadTaskStatus.Completed;
Owner.OnComplete(this);
}

private async Task<GetNameAndSizeResult> GetNameAndSize()
{
var uri = new Uri(_url);
if (uri.IsFile)
return new GetNameAndSizeResult(string.Empty, -1);

var response = await _client.GetAsync(_url, HttpCompletionOption.ResponseHeadersRead);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("HTTP request {Url} failed with status {ResponseStatusCode}", _url, response.StatusCode);
return new GetNameAndSizeResult(string.Empty, -1);
}

// Get the filename from the Content-Disposition header, or default to a temporary file name.
var contentDispositionHeader = response.Content.Headers.ContentDisposition?.FileNameStar
?? response.Content.Headers.ContentDisposition?.FileName
?? Path.GetTempFileName();

return new GetNameAndSizeResult(contentDispositionHeader.Trim('"'), response.Content.Headers.ContentLength.GetValueOrDefault(0));
}

public void Cancel()
{
try { _tokenSource.Cancel(); }
catch (Exception) { /* ignored */ }

// Do not _task.Wait() here, as it will deadlock without async.
Owner.OnCancelled(this);
}

public void Pause()
{
Status = DownloadTaskStatus.Paused;
throw new NotImplementedException();
}

public void Resume() => throw new NotImplementedException();
private record GetNameAndSizeResult(string FileName, long FileSize);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using NexusMods.DataModel.Loadouts;
using NexusMods.DataModel.RateLimiting;
using NexusMods.Networking.NexusWebApi.Types;
using NexusMods.Paths;

namespace NexusMods.Networking.Downloaders.Interfaces;

/// <summary>
/// This is a 'download manager' of sorts.
/// This service contains all of the downloads which have begun, or have already started.
/// </summary>
public interface IDownloadService
{
/// <summary>
/// Contains all downloads managed by the application.
/// </summary>
List<IDownloadTask> Downloads { get; }

/// <summary>
/// This gets fired whenever a download-and-install task is started.
/// </summary>
IObservable<IDownloadTask> StartedTasks { get; }

/// <summary>
/// This gets fired whenever a status of download-and-install task is completed.
/// This happens when <see cref="JobState.Finished"/> is true.
/// </summary>
IObservable<IDownloadTask> CompletedTasks { get; }

/// <summary>
/// This gets fired whenever a status of download-and-install task is completed.
/// This happens when <see cref="JobState.Finished"/> is true.
/// </summary>
IObservable<IDownloadTask> CancelledTasks { get; }

/// <summary>
/// This gets fired whenever a download-and-install task is paused.
/// This happens when <see cref="JobState.Paused"/> is true.
/// </summary>
IObservable<IDownloadTask> PausedTasks { get; }

/// <summary>
/// This gets fired whenever a download-and-install task is resumed.
/// This happens when <see cref="JobState.Running"/> is true after <see cref="JobState.Paused"/>.
/// </summary>
IObservable<IDownloadTask> ResumedTasks { get; }

/// <summary>
/// Adds a task that will download from a NXM link.
/// </summary>
/// <param name="url">Url to download from.</param>
void AddNxmTask(NXMUrl url);

/// <summary>
/// Adds a task that will download from a HTTP link.
/// </summary>
/// <param name="url">Url to download from.</param>
/// <param name="loadout">Loadout for the task.</param>
void AddHttpTask(string url, Loadout loadout);

/// <summary>
/// Adds a task to the download queue.
/// </summary>
/// <param name="task">A task which has not yet been started.</param>
void AddTask(IDownloadTask task);

/// <summary>
/// This is a callback fired by individual implementations of <see cref="IDownloadTask"/>.
/// Fires off the necessary events.
/// </summary>
void OnComplete(IDownloadTask task);

/// <summary>
/// This is a callback fired by individual implementations of <see cref="IDownloadTask"/>.
/// Fires off the necessary events.
/// </summary>
void OnCancelled(IDownloadTask task);

/// <summary>
/// This is a callback fired by individual implementations of <see cref="IDownloadTask"/>.
/// Fires off the necessary events.
/// </summary>
void OnPaused(IDownloadTask task);

/// <summary>
/// This is a callback fired by individual implementations of <see cref="IDownloadTask"/>.
/// Fires off the necessary events.
/// </summary>
void OnResumed(IDownloadTask task);

/// <summary>
/// Gets the total throughput of all download operations in bytes per second.
/// </summary>
Size GetThroughput();
}
Loading

0 comments on commit f4ac863

Please sign in to comment.