Ruben 7 months ago
parent
commit
3f1bb337d7

+ 113 - 33
src/PicView.Core/Http/HttpClientDownloadWithProgress.cs

@@ -1,60 +1,140 @@
-namespace PicView.Core.Http;
+using System.Net;
 
-public sealed class HttpClientDownloadWithProgress(string downloadUrl, string destinationFilePath) : IDisposable
+namespace PicView.Core.Http;
+
+public sealed class HttpClientDownloadWithProgress : IDisposable
 {
     public delegate void ProgressChangedHandler(long? totalFileSize, long? totalBytesDownloaded,
         double? progressPercentage);
 
+    private readonly string _downloadUrl;
+    private readonly string _destinationFilePath;
+    private readonly HttpClient _httpClient;
     private bool _disposed;
-    private HttpClient? _httpClient;
 
     public event ProgressChangedHandler? ProgressChanged;
+    
+    /// <summary>
+    /// Initializes a new instance of HttpClientDownloadWithProgress
+    /// </summary>
+    /// <param name="downloadUrl">URL to download from</param>
+    /// <param name="destinationFilePath">Where to save the downloaded file</param>
+    /// <param name="httpClient">Optional custom HttpClient instance</param>
+    public HttpClientDownloadWithProgress(string downloadUrl, string destinationFilePath, HttpClient? httpClient = null)
+    {
+        _downloadUrl = downloadUrl ?? throw new ArgumentNullException(nameof(downloadUrl));
+        _destinationFilePath = destinationFilePath ?? throw new ArgumentNullException(nameof(destinationFilePath));
+        _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromHours(1) };
+    }
 
-    public async Task StartDownloadAsync()
+    /// <summary>
+    /// Starts downloading the file asynchronously
+    /// </summary>
+    /// <param name="cancellationToken">Token to cancel the download</param>
+    /// <returns>Task representing the download operation</returns>
+    /// <exception cref="HttpRequestException">Thrown when the download fails</exception>
+    public async Task StartDownloadAsync(CancellationToken cancellationToken = default)
     {
-        _httpClient = new HttpClient { Timeout = TimeSpan.FromHours(6) };
-        using var response = await _httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead)
-            .ConfigureAwait(false);
-        await DownloadFileFromHttpResponseMessage(response).ConfigureAwait(false);
+        try
+        {
+            using var response = await _httpClient.GetAsync(
+                _downloadUrl, 
+                HttpCompletionOption.ResponseHeadersRead,
+                cancellationToken).ConfigureAwait(false);
+                
+            await DownloadFileFromHttpResponseMessage(response, cancellationToken).ConfigureAwait(false);
+        }
+        catch (TaskCanceledException)
+        {
+            // Clean up partial downloads
+            if (File.Exists(_destinationFilePath))
+            {
+                try { File.Delete(_destinationFilePath); } catch { /* Ignore cleanup failures */ }
+            }
+            throw;
+        }
+        catch (Exception ex)
+        {
+            throw new HttpRequestException($"Failed to download file from {_downloadUrl}: {ex.Message}", ex);
+        }
     }
 
-    private async Task DownloadFileFromHttpResponseMessage(HttpResponseMessage response)
+    private async Task DownloadFileFromHttpResponseMessage(
+        HttpResponseMessage response, 
+        CancellationToken cancellationToken)
     {
-        response.EnsureSuccessStatusCode();
+        if (!response.IsSuccessStatusCode)
+        {
+            if (response.StatusCode == HttpStatusCode.NotFound)
+                throw new FileNotFoundException($"The requested file at {_downloadUrl} was not found.", _downloadUrl);
+                
+            throw new HttpRequestException(
+                $"Download failed with status code {response.StatusCode}: {response.ReasonPhrase}");
+        }
+        
         var totalBytes = response.Content.Headers.ContentLength;
-        await using var contentStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
-        await ProcessContentStream(totalBytes, contentStream).ConfigureAwait(false);
+        await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+        await ProcessContentStream(totalBytes, contentStream, cancellationToken).ConfigureAwait(false);
     }
 
-    private async Task ProcessContentStream(long? totalDownloadSize, Stream contentStream)
+    private async Task ProcessContentStream(
+        long? totalDownloadSize, 
+        Stream contentStream,
+        CancellationToken cancellationToken)
     {
-        const int bufferSize = 8192;
+        const int bufferSize = 81920; // Larger buffer for better performance
         var buffer = new byte[bufferSize];
-        await using var fileStream = new FileStream(destinationFilePath, FileMode.Create, FileAccess.Write,
-            FileShare.None, bufferSize, true);
         var totalBytesRead = 0L;
-        do
+        
+        // Ensure the directory exists
+        var directory = Path.GetDirectoryName(_destinationFilePath);
+        if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
         {
-            var bytesRead = await contentStream.ReadAsync(buffer).ConfigureAwait(false);
-            if (bytesRead == 0)
-            {
-                break;
-            }
-
-            await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead)).ConfigureAwait(false);
-            totalBytesRead += bytesRead;
+            Directory.CreateDirectory(directory);
+        }
 
-            if (!totalDownloadSize.HasValue)
+        await using var fileStream = new FileStream(
+            _destinationFilePath, 
+            FileMode.Create, 
+            FileAccess.Write,
+            FileShare.None, 
+            bufferSize, 
+            true);
+            
+        int bytesRead;
+        
+        do
+        {
+            bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
+            
+            if (bytesRead > 0)
             {
-                continue;
+                await fileStream.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false);
+                totalBytesRead += bytesRead;
+
+                if (totalDownloadSize.HasValue)
+                {
+                    var progressPercentage = (double)totalBytesRead / totalDownloadSize.Value * 100;
+                    OnProgressChanged(totalDownloadSize, totalBytesRead, progressPercentage);
+                }
+                else
+                {
+                    // If we don't know the total size, just report bytes downloaded
+                    OnProgressChanged(null, totalBytesRead, null);
+                }
             }
-
-            var progressPercentage = (double)totalBytesRead / totalDownloadSize.Value * 100;
-            OnProgressChanged(totalDownloadSize, totalBytesRead, progressPercentage);
-        } while (true);
+        } while (bytesRead > 0 && !cancellationToken.IsCancellationRequested);
+        
+        // Flush to ensure all data is written
+        await fileStream.FlushAsync(cancellationToken).ConfigureAwait(false);
+        
+        if (cancellationToken.IsCancellationRequested)
+        {
+            throw new TaskCanceledException("Download was canceled");
+        }
     }
 
-    private void OnProgressChanged(long? totalDownloadSize, long totalBytesRead, double progressPercentage)
+    private void OnProgressChanged(long? totalDownloadSize, long totalBytesRead, double? progressPercentage)
     {
         ProgressChanged?.Invoke(totalDownloadSize, totalBytesRead, progressPercentage);
     }
@@ -76,7 +156,7 @@ public sealed class HttpClientDownloadWithProgress(string downloadUrl, string de
 
         if (disposing)
         {
-            _httpClient?.Dispose();
+            _httpClient.Dispose();
         }
 
         _disposed = true;

+ 112 - 26
src/PicView.Core/Http/HttpManager.cs

@@ -5,53 +5,139 @@ namespace PicView.Core.Http;
 
 public static class HttpManager
 {
-    public struct HttpDownload
+    public class HttpDownload
     {
-        public string DownloadPath { get; init; }
+        public string DownloadPath { get; init; } = string.Empty;
         public HttpClientDownloadWithProgress? Client { get; init; }
     }
     
-    public static HttpDownload GetDownloadClient(string url)
+    /// <summary>
+    /// Creates a download client and prepares temporary file path
+    /// </summary>
+    /// <param name="url">URL to download from</param>
+    /// <param name="customPath">Optional custom path to save the file, instead of temp directory</param>
+    /// <returns>HttpDownload object with client and destination path</returns>
+    /// <exception cref="Exception">Thrown when unable to create temp directory</exception>
+    public static HttpDownload GetDownloadClient(string url, string? customPath = null)
     {
-        // Create temp directory
-        var createTempPath = TempFileHelper.CreateTempDirectory();
-        var tempPath = TempFileHelper.TempFilePath;
-        if (createTempPath == false)
+        string downloadPath;
+        
+        if (customPath != null)
         {
-            throw new Exception(TranslationHelper.GetTranslation("UnexpectedError"));
+            downloadPath = customPath;
+            Directory.CreateDirectory(Path.GetDirectoryName(downloadPath) ?? throw new Exception(TranslationHelper.GetTranslation("UnexpectedError")));
         }
-        
+        else
+        {
+            // Create temp directory
+            var createTempPath = TempFileHelper.CreateTempDirectory();
+            var tempPath = TempFileHelper.TempFilePath;
+            if (!createTempPath)
+            {
+                throw new Exception(TranslationHelper.GetTranslation("UnexpectedError"));
+            }
+            
+            var fileName = GetSafeFileName(url);
+            downloadPath = Path.Combine(tempPath, fileName);
+            TempFileHelper.TempFilePath = string.Empty; // Reset it, since not browsing archive
+        }
+
+        var client = new HttpClientDownloadWithProgress(url, downloadPath);
+
+        return new HttpDownload
+        {
+            DownloadPath = downloadPath,
+            Client = client
+        };
+    }
+    
+    /// <summary>
+    /// Gets a file name from URL that is safe to use as a file path
+    /// </summary>
+    /// <param name="url">URL to extract filename from</param>
+    /// <returns>Safe filename</returns>
+    public static string GetSafeFileName(string url)
+    {
         var fileName = Path.GetFileName(url);
 
-        // Remove past "?" to not get file exceptions
+        // Remove query string parameters to avoid file exceptions
         var index = fileName.IndexOf('?');
         if (index >= 0)
         {
             fileName = fileName[..index];
         }
 
-        tempPath = Path.Combine(tempPath, fileName);
-        TempFileHelper.TempFilePath = string.Empty; // Reset it, since not browsing archive
-
-        var client = new HttpClientDownloadWithProgress(url, tempPath);
-
-        return new HttpDownload
+        // If filename is empty or invalid after processing, use a default name
+        if (string.IsNullOrWhiteSpace(fileName) || fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
         {
-            DownloadPath = tempPath,
-            Client = client
-        };
+            fileName = $"download_{DateTime.Now:yyyyMMdd_HHmmss}";
+        }
+
+        return fileName;
     }
     
-    public static string GetProgressDisplay(long? totalFileSize, long? totalBytesDownloaded,
-        double? progressPercentage)
+    /// <summary>
+    /// Formats download progress information for display
+    /// </summary>
+    /// <param name="totalFileSize">Total file size in bytes</param>
+    /// <param name="totalBytesDownloaded">Downloaded bytes</param>
+    /// <param name="progressPercentage">Progress percentage</param>
+    /// <returns>Formatted string showing download progress</returns>
+    public static string GetProgressDisplay(long? totalFileSize, long? totalBytesDownloaded, double? progressPercentage)
     {
         if (!totalFileSize.HasValue || !totalBytesDownloaded.HasValue || !progressPercentage.HasValue) 
             return string.Empty;
 
         var percentComplete = TranslationHelper.Translation.PercentComplete;
-        var displayProgress =
-            $"{(int)totalBytesDownloaded}/{(int)totalBytesDownloaded} {(int)progressPercentage} {percentComplete}";
-
-        return displayProgress;
+        var downloadedMb = FormatFileSize(totalBytesDownloaded.Value);
+        var totalMb = FormatFileSize(totalFileSize.Value);
+        
+        return $"{downloadedMb}/{totalMb} ({(int)progressPercentage.Value}% {percentComplete})";
+    }
+    
+    /// <summary>
+    /// Formats file size to a human-readable format
+    /// </summary>
+    /// <param name="bytes">Size in bytes</param>
+    /// <returns>Formatted file size</returns>
+    private static string FormatFileSize(long bytes)
+    {
+        string[] sizes = ["B", "KB", "MB", "GB", "TB"];
+        var order = 0;
+        double size = bytes;
+        
+        while (size >= 1024 && order < sizes.Length - 1)
+        {
+            order++;
+            size /= 1024;
+        }
+        
+        return $"{size:0.##} {sizes[order]}";
+    }
+    
+    /// <summary>
+    /// Downloads a file from a URL and returns the local file path
+    /// </summary>
+    /// <param name="url">URL to download</param>
+    /// <param name="progressCallback">Callback for download progress</param>
+    /// <param name="cancellationToken">Cancellation token</param>
+    /// <returns>Path to the downloaded file</returns>
+    public static async Task<string> DownloadFileAsync(
+        string url, 
+        Action<long?, long?, double?>? progressCallback = null,
+        CancellationToken cancellationToken = default)
+    {
+        var download = GetDownloadClient(url);
+        
+        if (download.Client == null)
+            throw new InvalidOperationException("Failed to create download client");
+            
+        if (progressCallback != null)
+            download.Client.ProgressChanged += (size, downloaded, percentage) => 
+                progressCallback(size, downloaded, percentage);
+                
+        await download.Client.StartDownloadAsync(cancellationToken);
+        
+        return download.DownloadPath;
     }
-}
+}