MossyUpdater/Services/DownloadService.cs
2025-05-31 00:06:18 +02:00

224 lines
No EOL
9.6 KiB
C#

using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
namespace MossyUpdater.Services
{
public class DownloadService : IDownloadService
{
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
private const int MaxRetries = 3;
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2);
public DownloadService(HttpClient httpClient, ILogger logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<DownloadResult> DownloadFileAsync(string url, string destinationPath, IProgress<DownloadProgress>? progress = null, CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var result = new DownloadResult();
await _logger.LogInfoAsync($"Starting download from {url} to {destinationPath}");
for (int attempt = 1; attempt <= MaxRetries; attempt++)
{
try
{
if (attempt > 1)
{
progress?.Report(new DownloadProgress { Status = $"Retrying download (attempt {attempt}/{MaxRetries})..." });
await _logger.LogInfoAsync($"Retry attempt {attempt}/{MaxRetries}");
await Task.Delay(RetryDelay, cancellationToken);
}
else
{
progress?.Report(new DownloadProgress { Status = "Starting download..." });
}
using var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength;
progress?.Report(new DownloadProgress { TotalBytes = totalBytes, Status = "Downloading..." });
var directory = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 8192, useAsync: true);
var buffer = new byte[8192];
long totalBytesRead = 0;
int bytesRead;
while ((bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0)
{
await fileStream.WriteAsync(buffer, 0, bytesRead, cancellationToken);
totalBytesRead += bytesRead;
progress?.Report(new DownloadProgress
{
BytesReceived = totalBytesRead,
TotalBytes = totalBytes,
Status = $"Downloaded {FormatBytes(totalBytesRead)}" + (totalBytes.HasValue ? $" of {FormatBytes(totalBytes.Value)}" : "")
});
}
result.Success = true;
result.BytesDownloaded = totalBytesRead;
progress?.Report(new DownloadProgress
{
BytesReceived = totalBytesRead,
TotalBytes = totalBytes,
Status = "Download completed successfully"
});
await _logger.LogInfoAsync($"Download completed successfully. Downloaded {FormatBytes(totalBytesRead)} in {stopwatch.Elapsed:mm\\:ss}");
break;
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException && attempt < MaxRetries)
{
await _logger.LogWarningAsync($"Timeout on attempt {attempt}: {ex.Message}");
continue;
}
catch (OperationCanceledException)
{
result.ErrorMessage = "Download was cancelled";
progress?.Report(new DownloadProgress { Status = "Download cancelled" });
await _logger.LogInfoAsync("Download was cancelled by user");
if (File.Exists(destinationPath))
{
try { File.Delete(destinationPath); } catch { }
}
break;
}
catch (HttpRequestException ex) when (IsRetryableHttpError(ex) && attempt < MaxRetries)
{
await _logger.LogWarningAsync($"Retryable error on attempt {attempt}: {ex.Message}");
continue;
}
catch (Exception ex)
{
result.ErrorMessage = GetFriendlyErrorMessage(ex);
progress?.Report(new DownloadProgress { Status = $"Error: {result.ErrorMessage}" });
await _logger.LogErrorAsync($"Download failed on attempt {attempt}", ex);
if (File.Exists(destinationPath))
{
try { File.Delete(destinationPath); } catch { }
}
if (attempt >= MaxRetries)
break;
}
}
stopwatch.Stop();
result.Duration = stopwatch.Elapsed;
return result;
}
public async Task<FileUpdateInfo> CheckForUpdatesAsync(string url, string localFilePath, CancellationToken cancellationToken = default)
{
var updateInfo = new FileUpdateInfo();
try
{
var request = new HttpRequestMessage(HttpMethod.Head, url);
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.IsSuccessStatusCode)
{
updateInfo.RemoteLastModified = response.Content.Headers.LastModified?.DateTime;
updateInfo.RemoteSize = response.Content.Headers.ContentLength;
if (File.Exists(localFilePath))
{
var localFileInfo = new FileInfo(localFilePath);
updateInfo.LocalLastModified = localFileInfo.LastWriteTime;
updateInfo.LocalSize = localFileInfo.Length;
updateInfo.UpdateAvailable =
updateInfo.RemoteLastModified.HasValue &&
updateInfo.RemoteLastModified > updateInfo.LocalLastModified ||
(updateInfo.RemoteSize.HasValue && updateInfo.RemoteSize != updateInfo.LocalSize);
}
else
{
updateInfo.UpdateAvailable = true;
}
}
else
{
updateInfo.ErrorMessage = $"Failed to check for updates: {response.StatusCode}";
}
}
catch (Exception ex)
{
updateInfo.ErrorMessage = $"Error checking for updates: {ex.Message}";
}
return updateInfo;
}
public void UnblockFile(string filePath)
{
try
{
var zoneIdentifier = filePath + ":Zone.Identifier";
if (File.Exists(zoneIdentifier))
{
File.Delete(zoneIdentifier);
}
}
catch
{
}
}
private static string FormatBytes(long bytes)
{
string[] suffixes = { "B", "KB", "MB", "GB", "TB" };
int counter = 0;
decimal number = bytes;
while (Math.Round(number / 1024) >= 1)
{
number /= 1024;
counter++;
}
return $"{number:n1} {suffixes[counter]}";
}
private static bool IsRetryableHttpError(HttpRequestException ex)
{
return ex.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("connection", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("network", StringComparison.OrdinalIgnoreCase);
}
private static string GetFriendlyErrorMessage(Exception ex)
{
return ex switch
{
HttpRequestException httpEx when httpEx.Message.Contains("404") => "File not found on server",
HttpRequestException httpEx when httpEx.Message.Contains("403") => "Access denied to file",
HttpRequestException httpEx when httpEx.Message.Contains("timeout") => "Download timed out",
UnauthorizedAccessException => "Access denied to destination folder",
DirectoryNotFoundException => "Destination folder does not exist",
IOException => "File operation failed",
_ => ex.Message
};
}
}
}