using Explorer.Application; using Explorer.Contracts; using Explorer.Domain; using Explorer.Domain.Abstractions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Explorer.FileOperations; public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroundWorkSignal { private readonly IOperationExecutor _executor; private readonly IIndexStore _store; private readonly IVolumeService _volumes; private readonly ILogger _logger; private readonly List _jobs = []; private readonly object _gate = new(); private readonly SemaphoreSlim _signal = new(0); private readonly Func _pauseRequested; private volatile bool _queuePaused; private volatile bool _haltPause; private long? _holdJobId; private CancellationTokenSource? _runningCts; private DateTime _lastChangedUtc = DateTime.MinValue; private int _restored; public event EventHandler? Changed; public event EventHandler? JobFinished; public TransferQueue( IOperationExecutor executor, IIndexStore store, IVolumeService volumes, ILogger logger) { _executor = executor; _store = store; _volumes = volumes; _logger = logger; _pauseRequested = () => _haltPause; } public bool IsPaused => _queuePaused; public bool HasForegroundWork() { lock (_gate) { return _jobs.Any(j => j.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Cancelling or TransferStatus.Waiting); } } public IReadOnlyList Snapshot() { lock (_gate) { return _jobs.ToList(); } } public async Task EnqueueCopyAsync(IReadOnlyList sources, string destinationDirectory, CancellationToken cancellationToken = default) { foreach (var src in sources) { var dest = Path.Combine(destinationDirectory, PathRules.GetFileName(src)); await EnqueueAsync(new TransferJob { Op = TransferOp.Copy, SourcePath = src, DestinationPath = dest, Status = TransferStatus.Queued, CreatedUtc = DateTimeOffset.UtcNow }, cancellationToken).ConfigureAwait(false); } } public async Task EnqueueMoveAsync(IReadOnlyList sources, string destinationDirectory, CancellationToken cancellationToken = default) { foreach (var src in sources) { var dest = Path.Combine(destinationDirectory, PathRules.GetFileName(src)); await EnqueueAsync(new TransferJob { Op = TransferOp.Move, SourcePath = src, DestinationPath = dest, Status = TransferStatus.Queued, CreatedUtc = DateTimeOffset.UtcNow }, cancellationToken).ConfigureAwait(false); } } public async Task EnqueueDeleteAsync(IReadOnlyList paths, bool permanent = false, CancellationToken cancellationToken = default) { await EnqueueAsync(new TransferJob { Op = TransferOp.Delete, SourcePath = string.Join("|", paths), DestinationPath = permanent ? "permanent" : "recycle", Status = TransferStatus.Queued, CreatedUtc = DateTimeOffset.UtcNow, AdditionalSources = paths.ToList() }, cancellationToken).ConfigureAwait(false); } public Task EnqueueRenameAsync(string path, string newName, CancellationToken cancellationToken = default) { var dest = Path.Combine(PathRules.Parent(path), newName); return EnqueueAsync(new TransferJob { Op = TransferOp.Rename, SourcePath = path, DestinationPath = dest, Status = TransferStatus.Queued, CreatedUtc = DateTimeOffset.UtcNow }, cancellationToken); } public Task EnqueueEmptyRecycleBinAsync(CancellationToken cancellationToken = default) => EnqueueAsync(new TransferJob { Op = TransferOp.EmptyRecycleBin, SourcePath = LocationRoots.RecycleBin, Status = TransferStatus.Queued, CreatedUtc = DateTimeOffset.UtcNow }, cancellationToken); public Task EnqueueExtractAsync(string archivePath, string destinationDirectory, CancellationToken cancellationToken = default) => EnqueueAsync(new TransferJob { Op = TransferOp.Extract, SourcePath = archivePath, DestinationPath = destinationDirectory, Status = TransferStatus.Queued, CreatedUtc = DateTimeOffset.UtcNow }, cancellationToken); public Task EnqueueCompressAsync(IReadOnlyList sources, string archivePath, CancellationToken cancellationToken = default) => EnqueueAsync(new TransferJob { Op = TransferOp.Compress, SourcePath = string.Join("|", sources), DestinationPath = archivePath, Status = TransferStatus.Queued, CreatedUtc = DateTimeOffset.UtcNow, AdditionalSources = sources.ToList() }, cancellationToken); public Task EnqueueAddToArchiveAsync(string archivePath, IReadOnlyList sources, CancellationToken cancellationToken = default) => EnqueueAsync(new TransferJob { Op = TransferOp.AddToArchive, SourcePath = string.Join("|", sources), DestinationPath = archivePath, Status = TransferStatus.Queued, CreatedUtc = DateTimeOffset.UtcNow, AdditionalSources = sources.ToList() }, cancellationToken); public Task EnqueueVerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default) => EnqueueAsync(new TransferJob { Op = TransferOp.VerifyArchive, SourcePath = archivePath, Status = TransferStatus.Queued, CreatedUtc = DateTimeOffset.UtcNow }, cancellationToken); public Task EnqueueConvertAsync(string sourcePath, string destinationPath, ConversionKind kind, CancellationToken cancellationToken = default) => EnqueueAsync(new TransferJob { Op = TransferOp.Convert, SourcePath = sourcePath, DestinationPath = destinationPath, Status = TransferStatus.Queued, CreatedUtc = DateTimeOffset.UtcNow, AdditionalSources = [kind.ToString()] }, cancellationToken); public void PauseAll() { _queuePaused = true; _haltPause = true; RaiseChanged(); } public void ResumeAll() { _queuePaused = false; _haltPause = false; _holdJobId = null; lock (_gate) { foreach (var job in _jobs.Where(j => j.Status == TransferStatus.Paused && (j.StartedUtc is not null || j.CurrentPath is not null))) { job.Status = TransferStatus.Queued; QueuePersist(job); } } Pulse(); RaiseChanged(); } public void Pause(long jobId) { lock (_gate) { var job = Find(jobId); if (job is null) { return; } if (job.Status == TransferStatus.Queued) { job.Status = TransferStatus.Paused; QueuePersist(job); } else if (job.Status == TransferStatus.Running) { _haltPause = true; _holdJobId = jobId; } } RaiseChanged(); } public void Resume(long jobId) { lock (_gate) { var job = Find(jobId); if (job is null || job.Status != TransferStatus.Paused) { return; } job.Status = TransferStatus.Queued; QueuePersist(job); } _queuePaused = false; _haltPause = false; if (_holdJobId == jobId) { _holdJobId = null; } Pulse(); RaiseChanged(); } public void Retry(long jobId) { lock (_gate) { var job = Find(jobId); if (job is null || job.Status != TransferStatus.Failed) { return; } job.RetryCount++; job.Status = TransferStatus.Queued; job.Error = null; job.WaitReason = null; QueuePersist(job); } Pulse(); RaiseChanged(); } public void Cancel(long jobId) { CancellationTokenSource? running = null; lock (_gate) { var job = Find(jobId); if (job is null) { return; } if (job.Status is TransferStatus.Queued or TransferStatus.Paused or TransferStatus.Waiting) { job.Status = TransferStatus.Cancelled; QueuePersist(job); _jobs.Remove(job); if (_holdJobId == jobId) { _holdJobId = null; } Pulse(); } else if (job.Status is TransferStatus.Running or TransferStatus.Cancelling) { job.Status = TransferStatus.Cancelling; _haltPause = false; running = _runningCts; } else { job.Dismissed = true; QueuePersist(job); _jobs.Remove(job); } } running?.Cancel(); RaiseChanged(); } public void Dismiss(long jobId) { lock (_gate) { var job = Find(jobId); if (job is null || job.Status is not (TransferStatus.Done or TransferStatus.Cancelled or TransferStatus.Failed)) { return; } job.Dismissed = true; QueuePersist(job); _jobs.Remove(job); } RaiseChanged(); } public void ClearFinished() { var removed = false; lock (_gate) { foreach (var job in _jobs.Where(j => j.Status is TransferStatus.Done or TransferStatus.Cancelled).ToList()) { job.Dismissed = true; QueuePersist(job); _jobs.Remove(job); removed = true; } } if (removed) { RaiseChanged(); } } public bool MoveUp(long jobId) => Move(jobId, -1); public bool MoveDown(long jobId) => Move(jobId, 1); public bool Move(long jobId, int delta) { if (delta == 0) { return false; } lock (_gate) { var index = _jobs.FindIndex(j => j.Id == jobId); var target = index + delta; if (index < 0 || target < 0 || target >= _jobs.Count) { return false; } var job = _jobs[index]; if (job.Status is TransferStatus.Running or TransferStatus.Cancelling) { return false; } _jobs.RemoveAt(index); _jobs.Insert(target, job); PersistOrder(); } Pulse(); RaiseChanged(); return true; } public void NotifyAvailability() { var resumed = false; lock (_gate) { foreach (var job in _jobs.Where(j => j.Status == TransferStatus.Waiting)) { if (!OperationAvailability.IsReady(_volumes, job)) { continue; } job.Status = TransferStatus.Queued; job.WaitReason = null; QueuePersist(job); resumed = true; } } if (resumed) { Pulse(); RaiseChanged(); } } private async Task EnqueueAsync(TransferJob job, CancellationToken cancellationToken) { if (!OperationAvailability.IsReady(_volumes, job)) { job.Status = TransferStatus.Waiting; job.WaitReason = FileOperationErrors.DestinationUnavailable; } job.Id = await _store.Transfers.InsertAsync(job, cancellationToken).ConfigureAwait(false); lock (_gate) { if (_jobs.All(j => j.Id != job.Id)) { _jobs.Add(job); } } Pulse(); RaiseChanged(); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await RestoreAsync(stoppingToken).ConfigureAwait(false); while (!stoppingToken.IsCancellationRequested) { TransferJob? job; lock (_gate) { job = CanStartNext() ? NextRunnable() : null; } if (job is null) { try { await _signal.WaitAsync(stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; } continue; } if (!OperationAvailability.IsReady(_volumes, job)) { job.Status = TransferStatus.Waiting; job.WaitReason = FileOperationErrors.DestinationUnavailable; await Persist(job).ConfigureAwait(false); RaiseChanged(); continue; } await RunAsync(job, stoppingToken).ConfigureAwait(false); } } private async Task RestoreAsync(CancellationToken cancellationToken) { if (Interlocked.Exchange(ref _restored, 1) == 1) { return; } IReadOnlyList incomplete; try { incomplete = await _store.Transfers.GetIncompleteAsync(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to restore file operations queue"); return; } var interrupted = new List(); lock (_gate) { var known = _jobs.Select(j => j.Id).ToHashSet(); foreach (var job in incomplete) { if (!known.Add(job.Id)) { continue; } if (job.Status is TransferStatus.Running or TransferStatus.Cancelling) { job.Status = TransferStatus.Paused; job.Error = null; interrupted.Add(job); } _jobs.Add(job); } } foreach (var job in interrupted) { await Persist(job).ConfigureAwait(false); } if (incomplete.Count > 0) { Pulse(); RaiseChanged(); } } private bool CanStartNext() { if (_queuePaused) { return false; } return _holdJobId is not { } id || !_jobs.Any(j => j.Id == id && j.Status is TransferStatus.Paused or TransferStatus.Running or TransferStatus.Cancelling); } private TransferJob? NextRunnable() => _jobs.FirstOrDefault(j => j.Status == TransferStatus.Queued); private async Task RunAsync(TransferJob job, CancellationToken stoppingToken) { using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); _runningCts = linked; _haltPause = false; job.Status = TransferStatus.Running; job.StartedUtc ??= DateTimeOffset.UtcNow; job.Error = null; job.WaitReason = null; await Persist(job).ConfigureAwait(false); RaiseChanged(); try { await _executor.ExecuteAsync(job, _pauseRequested, () => RaiseChanged(throttled: true), linked.Token) .ConfigureAwait(false); if (_haltPause && job.Status == TransferStatus.Paused) { _haltPause = false; } if (job.Status == TransferStatus.Paused) { await Persist(job).ConfigureAwait(false); RaiseChanged(); return; } if (job.Status == TransferStatus.Waiting) { await Persist(job).ConfigureAwait(false); RaiseChanged(); return; } if (job.Status == TransferStatus.Cancelling) { job.Status = TransferStatus.Cancelled; } else if (job.Status != TransferStatus.Failed && !string.IsNullOrEmpty(job.Error)) { job.Status = TransferStatus.Failed; } else if (job.Status != TransferStatus.Failed) { job.Status = TransferStatus.Done; job.CurrentPath = null; } } catch (OperationCanceledException) { job.Status = TransferStatus.Cancelled; } catch (Exception ex) { _logger.LogWarning(ex, "Transfer failed {Op} {Src}", job.Op, job.SourcePath); job.Status = TransferStatus.Failed; job.Error = FileOperationErrors.IsLock(ex.Message) ? FileOperationErrors.FileInUse : ex.Message; } finally { if (ReferenceEquals(_runningCts, linked)) { _runningCts = null; } } if (job.Status == TransferStatus.Paused) { return; } await Persist(job).ConfigureAwait(false); RaiseChanged(); JobFinished?.Invoke(this, job); } private TransferJob? Find(long jobId) => _jobs.FirstOrDefault(j => j.Id == jobId); private void Pulse() { if (_signal.CurrentCount == 0) { _signal.Release(); } } private void RaiseChanged(bool throttled = false) { if (throttled) { var now = DateTime.UtcNow; if ((now - _lastChangedUtc).TotalMilliseconds < 80) { return; } _lastChangedUtc = now; } Changed?.Invoke(this, EventArgs.Empty); } private void PersistOrder() { for (var i = 0; i < _jobs.Count; i++) { _jobs[i].SortOrder = i; QueuePersist(_jobs[i]); } } private void QueuePersist(TransferJob job) => _ = PersistSafe(job); private async Task PersistSafe(TransferJob job) { try { await Persist(job).ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to persist transfer job {Id}", job.Id); } } private Task Persist(TransferJob job) => _store.Transfers.UpdateAsync(job); }