Editor/QueueTracker.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace HumanoidMocapX.EditorTools;
/// <summary>The account's jobs on the provider, refreshed every few seconds while signed in.
/// Videos added beyond the provider's per-account limit wait here and upload one at a time as
/// jobs finish. Raises <see cref="Changed"/> on the editor's main thread.</summary>
sealed class QueueTracker : IDisposable
{
static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(3);
readonly HostingSettings settings;
readonly HostedCapture capture;
readonly Func<string, Task> openMotion;
readonly List<string> waiting = new();
// Local paths of videos sent this session, so opened results can preview their source.
readonly Dictionary<Guid, string> sources = new();
readonly CancellationTokenSource lifetime = new();
bool unreachable;
internal QueueTracker(HostingSettings settings, HostedCapture capture, Func<string, Task> openMotion)
{
this.settings = settings; this.capture = capture; this.openMotion = openMotion;
settings.Changed += SignInChanged;
_ = PollAsync();
}
internal event Action Changed;
internal HostedJob[] Jobs { get; private set; } = Array.Empty<HostedJob>();
internal IReadOnlyList<string> Waiting => waiting;
internal string Uploading { get; private set; }
internal HostedCapabilities Limits { get; private set; }
internal string Problem { get; private set; }
internal bool Ready => settings.SignedIn && settings.Profile is { CanProcess: true };
internal int Active => Jobs.Count(job => !Finished(job)) + waiting.Count + (Uploading is null ? 0 : 1);
internal static bool Finished(HostedJob job) => job.State is "completed" or "failed" or "cancelled";
void SignInChanged() { Limits = null; Changed?.Invoke(); }
internal void Add(IEnumerable<string> videos)
{
Problem = null;
waiting.AddRange(videos.Where(File.Exists).Except(waiting));
Changed?.Invoke();
}
internal void Remove(string video) { waiting.Remove(video); Changed?.Invoke(); }
async Task PollAsync()
{
var token = lifetime.Token;
while (!token.IsCancellationRequested)
{
if (Ready)
{
try
{
Limits ??= await capture.CapabilitiesAsync(token);
var current = await capture.ListAsync(token);
await EditorPipeline.SwitchToMainThread();
if (token.IsCancellationRequested) return;
Jobs = current;
if (unreachable) { unreachable = false; Problem = null; }
await SubmitWaitingAsync(token);
}
catch (Exception failure) when (failure is HttpRequestException or InvalidDataException or ArgumentException
or System.Text.Json.JsonException || failure is OperationCanceledException && !token.IsCancellationRequested)
{
await EditorPipeline.SwitchToMainThread();
if (token.IsCancellationRequested) return;
unreachable = true;
Problem = failure is OperationCanceledException ? "The provider did not answer in time." : failure.Message;
}
}
await EditorPipeline.SwitchToMainThread();
if (token.IsCancellationRequested) return;
Changed?.Invoke();
try { await Task.Delay(PollInterval, token); } catch (OperationCanceledException) { return; }
}
}
// One upload at a time, and only while the account is below the provider's job limit.
async Task SubmitWaitingAsync(CancellationToken token)
{
while (waiting.Count > 0 && Uploading is null && Jobs.Count(job => !Finished(job)) < (Limits?.MaximumActiveJobs ?? 1))
{
var video = waiting[0]; waiting.RemoveAt(0); Uploading = video; Changed?.Invoke();
try
{
var job = await capture.SubmitAsync(video, 0, null, token);
await EditorPipeline.SwitchToMainThread();
sources[job.Id] = video;
Jobs = await capture.ListAsync(token);
}
// One refused video (too long, too large, unreadable) must not hold up the rest.
catch (Exception failure) when (failure is HttpRequestException or InvalidDataException or IOException or ArgumentException)
{
await EditorPipeline.SwitchToMainThread();
Problem = Path.GetFileName(video) + ": " + failure.Message;
}
finally { await EditorPipeline.SwitchToMainThread(); Uploading = null; }
}
}
internal async Task CancelAsync(HostedJob job)
{
try { await capture.CancelAsync(job.Id, lifetime.Token); }
catch (HttpRequestException failure) when (failure.StatusCode != System.Net.HttpStatusCode.NotFound)
{ await EditorPipeline.SwitchToMainThread(); Problem = failure.Message; Changed?.Invoke(); return; }
await EditorPipeline.SwitchToMainThread();
Jobs = Jobs.Where(entry => entry.Id != job.Id).ToArray(); sources.Remove(job.Id); Changed?.Invoke();
}
internal async Task OpenAsync(HostedJob job)
{
try
{
var path = await capture.DownloadAsync(job.Id, sources.GetValueOrDefault(job.Id), lifetime.Token);
await EditorPipeline.SwitchToMainThread();
await openMotion(path);
}
catch (Exception failure) when (failure is HttpRequestException or InvalidDataException or IOException or FormatException
or System.Text.Json.JsonException)
{ await EditorPipeline.SwitchToMainThread(); Problem = failure.Message; Changed?.Invoke(); }
}
public void Dispose()
{
settings.Changed -= SignInChanged; lifetime.Cancel();
}
}