From ad964ab4535b567121751f196262c7ae6d094bc9 Mon Sep 17 00:00:00 2001 From: fabudakt <164002598+fabudakt@users.noreply.github.com> Date: Thu, 9 Oct 2025 13:27:17 +0200 Subject: [PATCH 1/6] Add custom chunk size support and fix total_chunk_hash calculation - Add optional chunkSize parameter to KDriveClient constructor - Fix total_chunk_hash calculation to hash concatenated chunk hash hex strings - Use IncrementalHash for efficient hash computation during file chunking --- DriveClient/Helpers/KDriveJsonContext.cs | 1 + DriveClient/Helpers/KDriveRequestFactory.cs | 8 +++--- DriveClient/Models/KDriveFile.cs | 27 +++++++++++++-------- DriveClient/Models/KDriveFinishRequest.cs | 16 ++++++++++++ DriveClient/kDriveClient/KDriveClient.cs | 20 ++++++++++++--- DriveClient/kDriveClient/SpeedTest.cs | 18 +++++++++++--- 6 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 DriveClient/Models/KDriveFinishRequest.cs diff --git a/DriveClient/Helpers/KDriveJsonContext.cs b/DriveClient/Helpers/KDriveJsonContext.cs index aaebc90..1892454 100644 --- a/DriveClient/Helpers/KDriveJsonContext.cs +++ b/DriveClient/Helpers/KDriveJsonContext.cs @@ -11,6 +11,7 @@ namespace kDriveClient.Helpers [JsonSerializable(typeof(KDriveFile))] [JsonSerializable(typeof(KDriveChunk))] [JsonSerializable(typeof(KDriveUploadResponseWraper))] + [JsonSerializable(typeof(KDriveFinishRequest))] [JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] public partial class KDriveJsonContext : JsonSerializerContext { diff --git a/DriveClient/Helpers/KDriveRequestFactory.cs b/DriveClient/Helpers/KDriveRequestFactory.cs index 5b62359..1a3de4c 100644 --- a/DriveClient/Helpers/KDriveRequestFactory.cs +++ b/DriveClient/Helpers/KDriveRequestFactory.cs @@ -74,10 +74,12 @@ public static HttpRequestMessage CreateChunkUploadRequest(string baseUrl, string /// An HttpRequestMessage configured to finish the upload session. public static HttpRequestMessage CreateFinishSessionRequest(long driveId, string sessionToken, string totalChunkHash) { - var content = new StringContent(JsonSerializer.Serialize(new + var finishRequest = new KDriveFinishRequest { - total_chunk_hash = $"sha256:{totalChunkHash.ToLowerInvariant()}" - }, KDriveJsonContext.Default.Object)); + TotalChunkHash = $"sha256:{totalChunkHash.ToLowerInvariant()}" + }; + + var content = new StringContent(JsonSerializer.Serialize(finishRequest, KDriveJsonContext.Default.KDriveFinishRequest)); content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); diff --git a/DriveClient/Models/KDriveFile.cs b/DriveClient/Models/KDriveFile.cs index 848447a..d84dcf7 100644 --- a/DriveClient/Models/KDriveFile.cs +++ b/DriveClient/Models/KDriveFile.cs @@ -1,4 +1,6 @@ -namespace kDriveClient.Models +using System.Text; + +namespace kDriveClient.Models { /// /// KDriveFile represents a file in the kDrive system. @@ -38,14 +40,7 @@ public class KDriveFile /// /// TotalChunkHash is the SHA-256 hash of the entire file content, computed from all chunks. /// - public string TotalChunkHash - { - get - { - return Convert.ToHexString( - SHA256.HashData(this.Content)); - } - } + public string TotalChunkHash { get; private set; } = string.Empty; /// /// TotalSize is the total size of the file in bytes, calculated as the sum of all chunk sizes. @@ -79,12 +74,24 @@ public void SplitIntoChunks(int chunkSize) this.Content.Position = 0; + // Use incremental hash to compute the total file hash as we read chunks + using var fileSha256 = System.Security.Cryptography.IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + while ((bytesRead = this.Content.Read(buffer, 0, chunkSize)) > 0) { byte[] content = [.. buffer.Take(bytesRead)]; - this.Chunks.Add(new KDriveChunk(content, chunkNumber++, SHA256.HashData(content))); + var chunkHash = SHA256.HashData(content); + this.Chunks.Add(new KDriveChunk(content, chunkNumber++, chunkHash)); + + // Add chunk hash hex string (lowercase) to compute total_chunk_hash + // According to kDrive API, total_chunk_hash is the hash of concatenated chunk hash hex strings + var chunkHashHex = Convert.ToHexString(chunkHash).ToLowerInvariant(); + fileSha256.AppendData(Encoding.UTF8.GetBytes(chunkHashHex)); } + // Compute and store the total chunk hash (hash of all chunk hash hex strings concatenated) + this.TotalChunkHash = Convert.ToHexString(fileSha256.GetHashAndReset()); + this.Content.Position = 0; } diff --git a/DriveClient/Models/KDriveFinishRequest.cs b/DriveClient/Models/KDriveFinishRequest.cs new file mode 100644 index 0000000..b02e2f9 --- /dev/null +++ b/DriveClient/Models/KDriveFinishRequest.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace kDriveClient.Models +{ + /// + /// Represents the request body for finishing an upload session. + /// + public class KDriveFinishRequest + { + /// + /// The total chunk hash of the uploaded file. + /// + [JsonPropertyName("total_chunk_hash")] + public string TotalChunkHash { get; set; } = string.Empty; + } +} diff --git a/DriveClient/kDriveClient/KDriveClient.cs b/DriveClient/kDriveClient/KDriveClient.cs index b3d0b06..c5666b0 100644 --- a/DriveClient/kDriveClient/KDriveClient.cs +++ b/DriveClient/kDriveClient/KDriveClient.cs @@ -81,7 +81,7 @@ public KDriveClient(string token, long driveId, ILogger? logger) : /// Choose if we should make a speed test to optimize chunks /// Number of parrallels threads /// Logger - public KDriveClient(string token, long driveId, bool autoChunk, int parallelism, ILogger? logger) : this(token, driveId, autoChunk, parallelism, logger, null) + public KDriveClient(string token, long driveId, bool autoChunk, int parallelism, ILogger? logger) : this(token, driveId, autoChunk, parallelism, logger, null, null) { } /// @@ -93,7 +93,8 @@ public KDriveClient(string token, long driveId, bool autoChunk, int parallelism, /// Number of parrallels threads /// Logger /// Custome HttpClient - public KDriveClient(string token, long driveId, bool autoChunk, int parallelism, ILogger? logger, HttpClient? httpClient = null) + /// Optional custom chunk size in bytes. If not specified, will be calculated dynamically based on speed test when autoChunk is true. + public KDriveClient(string token, long driveId, bool autoChunk, int parallelism, ILogger? logger, HttpClient? httpClient = null, int? chunkSize = null) { DriveId = driveId; Parallelism = parallelism; @@ -103,12 +104,25 @@ public KDriveClient(string token, long driveId, bool autoChunk, int parallelism, HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); HttpClient.DefaultRequestHeaders.UserAgent.ParseAdd("kDriveClient.NET/version"); this.Logger?.LogInformation("KDriveClient initialized with Drive ID: {DriveId}", DriveId); + + if (chunkSize.HasValue) + { + DynamicChunkSizeBytes = chunkSize.Value; + this.Logger?.LogInformation("Using custom chunk size: {ChunkSize} bytes", DynamicChunkSizeBytes); + } + if (autoChunk) { this.Logger?.LogInformation("Auto chunking enabled, initializing upload strategy..."); - InitializeUploadStrategyAsync().GetAwaiter().GetResult(); + InitializeUploadStrategyAsync(chunkSize).GetAwaiter().GetResult(); this.Logger?.LogInformation("Upload strategy initialized with direct upload threshold: {Threshold} bytes and dynamic chunk size: {ChunkSize} bytes", DirectUploadThresholdBytes, DynamicChunkSizeBytes); } + else if (!chunkSize.HasValue) + { + // If autoChunk is disabled and no custom chunk size provided, use a default + DynamicChunkSizeBytes = 1024 * 1024; // Default to 1MB chunks + this.Logger?.LogInformation("Using default chunk size: {ChunkSize} bytes", DynamicChunkSizeBytes); + } } /// diff --git a/DriveClient/kDriveClient/SpeedTest.cs b/DriveClient/kDriveClient/SpeedTest.cs index 753c025..97fb243 100644 --- a/DriveClient/kDriveClient/SpeedTest.cs +++ b/DriveClient/kDriveClient/SpeedTest.cs @@ -11,9 +11,10 @@ public partial class KDriveClient /// /// Initializes the upload strategy by performing a speed test. /// + /// Optional custom chunk size in bytes. If specified, only DirectUploadThresholdBytes will be calculated from speed test. /// Cancellation token to cancel the operation. /// A task that represents the asynchronous operation. - private async Task InitializeUploadStrategyAsync(CancellationToken ct = default) + private async Task InitializeUploadStrategyAsync(int? customChunkSize = null, CancellationToken ct = default) { this.Logger?.LogInformation("Starting upload strategy initialization..."); var buffer = new byte[1024 * 1024]; @@ -65,9 +66,18 @@ private async Task InitializeUploadStrategyAsync(CancellationToken ct = default) var speedBytesPerSec = buffer.Length / (sw.ElapsedMilliseconds / 1000.0); DirectUploadThresholdBytes = (long)speedBytesPerSec; - DynamicChunkSizeBytes = (int)(speedBytesPerSec * 0.9); - this.Logger?.LogInformation("Upload strategy initialized: DirectUploadThresholdBytes = {DirectUploadThresholdBytes}, DynamicChunkSizeBytes = {DynamicChunkSizeBytes}", - DirectUploadThresholdBytes, DynamicChunkSizeBytes); + + if (!customChunkSize.HasValue) + { + DynamicChunkSizeBytes = (int)(speedBytesPerSec * 0.9); + this.Logger?.LogInformation("Upload strategy initialized: DirectUploadThresholdBytes = {DirectUploadThresholdBytes}, DynamicChunkSizeBytes = {DynamicChunkSizeBytes} (calculated from speed test)", + DirectUploadThresholdBytes, DynamicChunkSizeBytes); + } + else + { + this.Logger?.LogInformation("Upload strategy initialized: DirectUploadThresholdBytes = {DirectUploadThresholdBytes}, DynamicChunkSizeBytes = {DynamicChunkSizeBytes} (custom)", + DirectUploadThresholdBytes, DynamicChunkSizeBytes); + } } } } \ No newline at end of file From dae73d9db4b6325ae3b44094b0c2bed8240e8977 Mon Sep 17 00:00:00 2001 From: anthonychaussin Date: Thu, 9 Oct 2025 17:18:21 +0200 Subject: [PATCH 2/6] - fix hash computation - bypass speed test if custom chunk size is provided - fix user-agent version --- DriveClient/Models/KDriveFile.cs | 29 ++++++++++++------------ DriveClient/kDriveClient/KDriveClient.cs | 16 ++++++------- DriveClient/kDriveClient/SpeedTest.cs | 24 +++++++++++--------- 3 files changed, 35 insertions(+), 34 deletions(-) diff --git a/DriveClient/Models/KDriveFile.cs b/DriveClient/Models/KDriveFile.cs index d84dcf7..936cd89 100644 --- a/DriveClient/Models/KDriveFile.cs +++ b/DriveClient/Models/KDriveFile.cs @@ -7,6 +7,7 @@ namespace kDriveClient.Models /// public class KDriveFile { + private string _totalChunkHash; /// /// CreatedAt is the timestamp when the file was created. /// @@ -40,7 +41,19 @@ public class KDriveFile /// /// TotalChunkHash is the SHA-256 hash of the entire file content, computed from all chunks. /// - public string TotalChunkHash { get; private set; } = string.Empty; + public string TotalChunkHash + { + get + { + if(this._totalChunkHash is null) + { + this._totalChunkHash = Convert.ToHexString( + SHA256.HashData(this.Content)); + } + + return this._totalChunkHash; + } + } /// /// TotalSize is the total size of the file in bytes, calculated as the sum of all chunk sizes. @@ -74,24 +87,12 @@ public void SplitIntoChunks(int chunkSize) this.Content.Position = 0; - // Use incremental hash to compute the total file hash as we read chunks - using var fileSha256 = System.Security.Cryptography.IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - while ((bytesRead = this.Content.Read(buffer, 0, chunkSize)) > 0) { byte[] content = [.. buffer.Take(bytesRead)]; - var chunkHash = SHA256.HashData(content); - this.Chunks.Add(new KDriveChunk(content, chunkNumber++, chunkHash)); - - // Add chunk hash hex string (lowercase) to compute total_chunk_hash - // According to kDrive API, total_chunk_hash is the hash of concatenated chunk hash hex strings - var chunkHashHex = Convert.ToHexString(chunkHash).ToLowerInvariant(); - fileSha256.AppendData(Encoding.UTF8.GetBytes(chunkHashHex)); + this.Chunks.Add(new KDriveChunk(content, chunkNumber++, SHA256.HashData(content))); } - // Compute and store the total chunk hash (hash of all chunk hash hex strings concatenated) - this.TotalChunkHash = Convert.ToHexString(fileSha256.GetHashAndReset()); - this.Content.Position = 0; } diff --git a/DriveClient/kDriveClient/KDriveClient.cs b/DriveClient/kDriveClient/KDriveClient.cs index c5666b0..96957fc 100644 --- a/DriveClient/kDriveClient/KDriveClient.cs +++ b/DriveClient/kDriveClient/KDriveClient.cs @@ -102,27 +102,25 @@ public KDriveClient(string token, long driveId, bool autoChunk, int parallelism, string version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown"; HttpClient = httpClient ?? new HttpClient { BaseAddress = new Uri("https://api.infomaniak.com") }; HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - HttpClient.DefaultRequestHeaders.UserAgent.ParseAdd("kDriveClient.NET/version"); + HttpClient.DefaultRequestHeaders.UserAgent.ParseAdd("kDriveClient.NET/"+version); this.Logger?.LogInformation("KDriveClient initialized with Drive ID: {DriveId}", DriveId); - if (chunkSize.HasValue) - { - DynamicChunkSizeBytes = chunkSize.Value; - this.Logger?.LogInformation("Using custom chunk size: {ChunkSize} bytes", DynamicChunkSizeBytes); - } - if (autoChunk) { this.Logger?.LogInformation("Auto chunking enabled, initializing upload strategy..."); InitializeUploadStrategyAsync(chunkSize).GetAwaiter().GetResult(); this.Logger?.LogInformation("Upload strategy initialized with direct upload threshold: {Threshold} bytes and dynamic chunk size: {ChunkSize} bytes", DirectUploadThresholdBytes, DynamicChunkSizeBytes); } - else if (!chunkSize.HasValue) + else if (chunkSize is null) // If autoChunk is disabled and no custom chunk size provided, use a default { - // If autoChunk is disabled and no custom chunk size provided, use a default DynamicChunkSizeBytes = 1024 * 1024; // Default to 1MB chunks this.Logger?.LogInformation("Using default chunk size: {ChunkSize} bytes", DynamicChunkSizeBytes); } + else + { + DynamicChunkSizeBytes = chunkSize.Value; + this.Logger?.LogInformation("Using custom chunk size: {ChunkSize} bytes", DynamicChunkSizeBytes); + } } /// diff --git a/DriveClient/kDriveClient/SpeedTest.cs b/DriveClient/kDriveClient/SpeedTest.cs index 97fb243..c69c92e 100644 --- a/DriveClient/kDriveClient/SpeedTest.cs +++ b/DriveClient/kDriveClient/SpeedTest.cs @@ -16,6 +16,16 @@ public partial class KDriveClient /// A task that represents the asynchronous operation. private async Task InitializeUploadStrategyAsync(int? customChunkSize = null, CancellationToken ct = default) { + if (customChunkSize != null) + { + DynamicChunkSizeBytes = (int)customChunkSize; + DirectUploadThresholdBytes = (int)customChunkSize; + this.Logger?.LogInformation("Custom chunk size is provided. Speed test is no longer needed"); + this.Logger?.LogInformation("Upload strategy initialized: DirectUploadThresholdBytes = {DirectUploadThresholdBytes}, DynamicChunkSizeBytes = {DynamicChunkSizeBytes} (custom)", + DirectUploadThresholdBytes, DynamicChunkSizeBytes); + return; + } + this.Logger?.LogInformation("Starting upload strategy initialization..."); var buffer = new byte[1024 * 1024]; RandomNumberGenerator.Fill(buffer); @@ -67,17 +77,9 @@ private async Task InitializeUploadStrategyAsync(int? customChunkSize = null, Ca DirectUploadThresholdBytes = (long)speedBytesPerSec; - if (!customChunkSize.HasValue) - { - DynamicChunkSizeBytes = (int)(speedBytesPerSec * 0.9); - this.Logger?.LogInformation("Upload strategy initialized: DirectUploadThresholdBytes = {DirectUploadThresholdBytes}, DynamicChunkSizeBytes = {DynamicChunkSizeBytes} (calculated from speed test)", - DirectUploadThresholdBytes, DynamicChunkSizeBytes); - } - else - { - this.Logger?.LogInformation("Upload strategy initialized: DirectUploadThresholdBytes = {DirectUploadThresholdBytes}, DynamicChunkSizeBytes = {DynamicChunkSizeBytes} (custom)", - DirectUploadThresholdBytes, DynamicChunkSizeBytes); - } + DynamicChunkSizeBytes = (int)(speedBytesPerSec * 0.9); + this.Logger?.LogInformation("Upload strategy initialized: DirectUploadThresholdBytes = {DirectUploadThresholdBytes}, DynamicChunkSizeBytes = {DynamicChunkSizeBytes} (calculated from speed test)", + DirectUploadThresholdBytes, DynamicChunkSizeBytes); } } } \ No newline at end of file From 47f377c5ef34a995710aed35ebb1769a41c13517 Mon Sep 17 00:00:00 2001 From: anthonychaussin Date: Tue, 14 Oct 2025 23:39:52 +0200 Subject: [PATCH 3/6] - Improve chunk size estimation - Improve memory management - Complete kDrive File Object - Fix upload response wrapper --- DriveClient/Helpers/KDriveJsonContext.cs | 1 + DriveClient/Helpers/KDriveJsonHelper.cs | 9 ++--- DriveClient/Helpers/KDriveRequestFactory.cs | 19 +++++---- DriveClient/Models/KDriveChunk.cs | 17 ++++++-- DriveClient/Models/KDriveFile.cs | 45 +++++++++++++-------- DriveClient/Models/KDriveUploadResponse.cs | 25 +++++++++++- DriveClient/kDriveClient.csproj | 6 ++- DriveClient/kDriveClient/KDriveClient.cs | 44 +++++++++++++++++--- DriveClient/kDriveClient/SpeedTest.cs | 14 +++++-- DriveClient/kDriveClient/Upload.cs | 35 ++++++++++------ 10 files changed, 157 insertions(+), 58 deletions(-) diff --git a/DriveClient/Helpers/KDriveJsonContext.cs b/DriveClient/Helpers/KDriveJsonContext.cs index 1892454..f2af8ee 100644 --- a/DriveClient/Helpers/KDriveJsonContext.cs +++ b/DriveClient/Helpers/KDriveJsonContext.cs @@ -12,6 +12,7 @@ namespace kDriveClient.Helpers [JsonSerializable(typeof(KDriveChunk))] [JsonSerializable(typeof(KDriveUploadResponseWraper))] [JsonSerializable(typeof(KDriveFinishRequest))] + [JsonSerializable(typeof(KDriveUploadDataResponse))] [JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] public partial class KDriveJsonContext : JsonSerializerContext { diff --git a/DriveClient/Helpers/KDriveJsonHelper.cs b/DriveClient/Helpers/KDriveJsonHelper.cs index 0991f38..467aa8c 100644 --- a/DriveClient/Helpers/KDriveJsonHelper.cs +++ b/DriveClient/Helpers/KDriveJsonHelper.cs @@ -16,7 +16,7 @@ public static class KDriveJsonHelper /// public static KDriveUploadResponse DeserializeUploadResponse(string json) { - return JsonSerializer.Deserialize(json, KDriveJsonContext.Default.KDriveUploadResponseWraper)?.Data ?? throw new InvalidOperationException("Failed to parse upload response"); + return JsonSerializer.Deserialize(json, KDriveJsonContext.Default.KDriveUploadResponseWraper)?.Data?.File ?? throw new InvalidOperationException("Failed to parse upload response"); } /// @@ -54,19 +54,18 @@ public static async Task DeserializeResponseAsync(HttpRespo { if (!response.IsSuccessStatusCode) { - var json = await response.Content.ReadAsStringAsync(ct); - KDriveErrorResponse? error = null; try { - error = JsonSerializer.Deserialize(json, KDriveJsonContext.Default.KDriveErrorResponse); + await using var stream = await response.Content.ReadAsStreamAsync(ct); + error = await JsonSerializer.DeserializeAsync( stream, KDriveJsonContext.Default.KDriveErrorResponse, cancellationToken: ct); } catch { response.EnsureSuccessStatusCode(); } - if (error != null) + if (error is not null) { throw new KDriveApiException(error); } diff --git a/DriveClient/Helpers/KDriveRequestFactory.cs b/DriveClient/Helpers/KDriveRequestFactory.cs index 1a3de4c..bbc5c57 100644 --- a/DriveClient/Helpers/KDriveRequestFactory.cs +++ b/DriveClient/Helpers/KDriveRequestFactory.cs @@ -1,4 +1,5 @@ using kDriveClient.Models; +using System.Net; using System.Net.Http.Headers; namespace kDriveClient.Helpers @@ -55,14 +56,14 @@ public static HttpRequestMessage CreateStartSessionRequest(long driveId, KDriveF /// An HttpRequestMessage configured for the chunk upload operation. public static HttpRequestMessage CreateChunkUploadRequest(string baseUrl, string token, long driveId, KDriveChunk chunk) { - var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/3/drive/{driveId}/upload/session/{token}/chunk?chunk_number={chunk.ChunkNumber + 1}&chunk_size={chunk.ChunkSize}&chunk_hash=sha256:{chunk.ChunkHash.ToLowerInvariant()}") + var content = new ReadOnlyMemoryContent(new ReadOnlyMemory(chunk.Content, 0, chunk.ChunkSize)); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + return new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/3/drive/{driveId}/upload/session/{token}/chunk?chunk_number={chunk.ChunkNumber + 1}&chunk_size={chunk.ChunkSize}&chunk_hash=sha256:{chunk.ChunkHash.ToLowerInvariant()}") { - Content = new ByteArrayContent(chunk.Content) + Content = content, + Version = HttpVersion.Version20, + VersionPolicy = HttpVersionPolicy.RequestVersionOrLower, }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); - request.Content.Headers.ContentLength = chunk.ChunkSize; - - return request; } /// @@ -74,12 +75,10 @@ public static HttpRequestMessage CreateChunkUploadRequest(string baseUrl, string /// An HttpRequestMessage configured to finish the upload session. public static HttpRequestMessage CreateFinishSessionRequest(long driveId, string sessionToken, string totalChunkHash) { - var finishRequest = new KDriveFinishRequest + var content = new StringContent(JsonSerializer.Serialize(new KDriveFinishRequest { TotalChunkHash = $"sha256:{totalChunkHash.ToLowerInvariant()}" - }; - - var content = new StringContent(JsonSerializer.Serialize(finishRequest, KDriveJsonContext.Default.KDriveFinishRequest)); + }, KDriveJsonContext.Default.KDriveFinishRequest)); content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); diff --git a/DriveClient/Models/KDriveChunk.cs b/DriveClient/Models/KDriveChunk.cs index 5700cda..b4b624c 100644 --- a/DriveClient/Models/KDriveChunk.cs +++ b/DriveClient/Models/KDriveChunk.cs @@ -3,7 +3,7 @@ /// /// kDriveChunk represents a chunk of Data in a kDrive file. /// - public class KDriveChunk(byte[] content, int chunkNumber, byte[] hash) + public class KDriveChunk(byte[] content, int chunkNumber, byte[] hash) : IDisposable { /// /// ChunkHash is the SHA-256 hash of the chunk content. @@ -18,11 +18,22 @@ public class KDriveChunk(byte[] content, int chunkNumber, byte[] hash) /// /// ChunkSize is the size of the chunk in bytes. /// - public long ChunkSize => this.Content.Length; + public int ChunkSize { get; set; } = content.Length; /// /// Content is the actual byte content of the chunk. /// - public byte[] Content { get; init; } = content; + public byte[]? Content { get; private set; } = content; + + public void Dispose() + { + GC.SuppressFinalize(this); + } + + internal void Clean() + { + this.Content = null; + GC.Collect(); + } } } \ No newline at end of file diff --git a/DriveClient/Models/KDriveFile.cs b/DriveClient/Models/KDriveFile.cs index 936cd89..9c0d463 100644 --- a/DriveClient/Models/KDriveFile.cs +++ b/DriveClient/Models/KDriveFile.cs @@ -5,9 +5,10 @@ namespace kDriveClient.Models /// /// KDriveFile represents a file in the kDrive system. /// - public class KDriveFile + public class KDriveFile : IDisposable { - private string _totalChunkHash; + private Int64 totalSize; + /// /// CreatedAt is the timestamp when the file was created. /// @@ -41,25 +42,23 @@ public class KDriveFile /// /// TotalChunkHash is the SHA-256 hash of the entire file content, computed from all chunks. /// - public string TotalChunkHash + public string TotalChunkHash { get; set; } + + /// + /// TotalSize is the total size of the file in bytes, calculated as the sum of all chunk sizes. + /// + public long TotalSize { get { - if(this._totalChunkHash is null) + if(totalSize == 0) { - this._totalChunkHash = Convert.ToHexString( - SHA256.HashData(this.Content)); + totalSize = this.Chunks.Sum(c => (long)c.ChunkSize); } - - return this._totalChunkHash; + return totalSize; } } - /// - /// TotalSize is the total size of the file in bytes, calculated as the sum of all chunk sizes. - /// - public long TotalSize => this.Chunks.Sum(c => c.ChunkSize); - /// /// Chunks is a list of KDriveChunk objects representing the file's content split into chunks. /// @@ -86,14 +85,21 @@ public void SplitIntoChunks(int chunkSize) int bytesRead; this.Content.Position = 0; - + using var fileSha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); while ((bytesRead = this.Content.Read(buffer, 0, chunkSize)) > 0) { byte[] content = [.. buffer.Take(bytesRead)]; - this.Chunks.Add(new KDriveChunk(content, chunkNumber++, SHA256.HashData(content))); + var chunkHash = SHA256.HashData(content); + this.Chunks.Add(new KDriveChunk(content, chunkNumber++, chunkHash)); + fileSha256.AppendData(Encoding.UTF8.GetBytes(Convert.ToHexString(chunkHash).ToLowerInvariant())); } - this.Content.Position = 0; + this.Content.Dispose(); + GC.Collect(); + + this.TotalChunkHash = this.Chunks.Count > 1 ? + this.TotalChunkHash = Convert.ToHexString(fileSha256.GetHashAndReset()) + : this.Chunks.First().ChunkHash; } /// @@ -119,5 +125,12 @@ public string ConvertConflictChoice() _ => "error" }; } + + public void Dispose() + { + this.Chunks.ForEach(c => c.Dispose()); + + GC.SuppressFinalize(this); + } } } \ No newline at end of file diff --git a/DriveClient/Models/KDriveUploadResponse.cs b/DriveClient/Models/KDriveUploadResponse.cs index 0441688..ee9338c 100644 --- a/DriveClient/Models/KDriveUploadResponse.cs +++ b/DriveClient/Models/KDriveUploadResponse.cs @@ -13,7 +13,15 @@ public class KDriveUploadResponseWraper : ApiResultBase /// /// Data containing the details of the uploaded file. /// - public KDriveUploadResponse? Data { get; set; } + public KDriveUploadDataResponse? Data { get; set; } + } + + public class KDriveUploadDataResponse : ApiResultBase + { + public KDriveUploadResponse File { get; set; } + public string Token { get; set; } + public bool Result { get; set; } + public string? Message { get; set; } } /// @@ -65,5 +73,20 @@ public class KDriveUploadResponse : ApiResultBase /// File hash. /// public string? Hash { get; set; } + public string Type { get; set; } + public string Status { get; set; } + public string Visibility { get; set; } + public int Drive_id { get; set; } + public int Depth { get; set; } + public int Created_by { get; set; } + public int Created_at { get; set; } + public int Added_at { get; set; } + public int Last_modified_at { get; set; } + public int Last_modified_by { get; set; } + public int Revised_at { get; set; } + public int Updated_at { get; set; } + public int Parent_id { get; set; } + public string Extension_type { get; set; } + public string Scan_status { get; set; } } } \ No newline at end of file diff --git a/DriveClient/kDriveClient.csproj b/DriveClient/kDriveClient.csproj index 7953db7..f432250 100644 --- a/DriveClient/kDriveClient.csproj +++ b/DriveClient/kDriveClient.csproj @@ -24,13 +24,15 @@ Native .NET logging support https://github.com/anthonychaussin/DriveClient infomaniak;kDrive;wrapper;c#;DotNet ✨ New features -- Added support for per-file `conflict_mode` (`error`, `rename`, `version`) via `KDriveFile.ConflictMode` +- Add optional chunkSize parameter to KDriveClient constructor +- bypass speed test if custom chunk size is provided +- fix user-agent version en LICENSE.txt True True link - 1.0.2 + 1.0.3 diff --git a/DriveClient/kDriveClient/KDriveClient.cs b/DriveClient/kDriveClient/KDriveClient.cs index 96957fc..8b9faff 100644 --- a/DriveClient/kDriveClient/KDriveClient.cs +++ b/DriveClient/kDriveClient/KDriveClient.cs @@ -1,5 +1,7 @@ using kDriveClient.Helpers; using kDriveClient.Models; +using System.Diagnostics; +using System.Net; using System.Net.Http.Headers; using System.Reflection; using System.Threading.RateLimiting; @@ -36,7 +38,7 @@ public partial class KDriveClient : IKDriveClient /// private RateLimiter RateLimiter { get; set; } = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions { - PermitLimit = 60, + PermitLimit = 59, Window = TimeSpan.FromMinutes(1), AutoReplenishment = true }); @@ -102,7 +104,7 @@ public KDriveClient(string token, long driveId, bool autoChunk, int parallelism, string version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown"; HttpClient = httpClient ?? new HttpClient { BaseAddress = new Uri("https://api.infomaniak.com") }; HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - HttpClient.DefaultRequestHeaders.UserAgent.ParseAdd("kDriveClient.NET/"+version); + HttpClient.DefaultRequestHeaders.UserAgent.ParseAdd("kDriveClient.NET/"+GetVersion()); this.Logger?.LogInformation("KDriveClient initialized with Drive ID: {DriveId}", DriveId); if (autoChunk) @@ -153,10 +155,19 @@ public async Task UploadAsync(KDriveFile file, Cancellatio /// protected virtual async Task SendAsync(HttpRequestMessage request, CancellationToken ct = default) { - if (!(await RateLimiter.AcquireAsync(1, ct)).IsAcquired) + using var lease = await RateLimiter.AcquireAsync(1, ct); + if (!lease.IsAcquired) { - Logger?.LogWarning("Rate limit exceeded for request: {RequestMethod} {RequestUri}", request.Method, request.RequestUri); - throw new HttpRequestException("Rate limit exceeded"); + if (lease.TryGetMetadata("RETRY_AFTER", out var obj) && obj is TimeSpan retryAfter) + { + Logger?.LogInformation("Rate limit reached. Retry-After {RetryAfter}", retryAfter); + await Task.Delay(retryAfter, ct); + } + else + { + Logger?.LogWarning("Rate limit exceeded for request: {RequestMethod} {RequestUri}", request.Method, request.RequestUri); + throw new HttpRequestException("Rate limit exceeded"); + } } Logger?.LogInformation("Sending request: {RequestMethod} {RequestUri}", request.Method, request.RequestUri); @@ -171,9 +182,30 @@ protected virtual async Task SendAsync(HttpRequestMessage r /// private async Task SendWithErrorHandlingAsync(HttpRequestMessage request, CancellationToken ct = default) { - var response = await HttpClient.SendAsync(request, ct); + var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); return await KDriveJsonHelper.DeserializeResponseAsync(response, ct); } + + static string GetVersion() + { + var asm = typeof(KDriveClient).Assembly; + var infoVer = asm.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(infoVer)) return infoVer; + var asmVer = asm.GetName().Version?.ToString(); + if (!string.IsNullOrWhiteSpace(asmVer)) return asmVer; + try + { + var loc = asm.Location; + if (!string.IsNullOrWhiteSpace(loc)) + { + var fvi = FileVersionInfo.GetVersionInfo(loc); + if (!string.IsNullOrWhiteSpace(fvi.FileVersion)) return fvi.FileVersion!; + } + } + catch {} + + return "unknown"; + } } } diff --git a/DriveClient/kDriveClient/SpeedTest.cs b/DriveClient/kDriveClient/SpeedTest.cs index c69c92e..3f2fd23 100644 --- a/DriveClient/kDriveClient/SpeedTest.cs +++ b/DriveClient/kDriveClient/SpeedTest.cs @@ -8,6 +8,10 @@ namespace kDriveClient.kDriveClient /// public partial class KDriveClient { + private const int MaxRequestsPerMinute = 60; + private const int MinChunkBytes = 100 * 1024 * 1024; + private const long MaxChunkBytes = 1L * 1024 * 1024 * 1024; + private const double Safety = 1.10; /// /// Initializes the upload strategy by performing a speed test. /// @@ -73,11 +77,15 @@ private async Task InitializeUploadStrategyAsync(int? customChunkSize = null, Ca await CancelUploadSessionRequest(SessionToken, ct); this.Logger?.LogInformation("Upload session finalized successfully."); - var speedBytesPerSec = buffer.Length / (sw.ElapsedMilliseconds / 1000.0); + var v = buffer.Length / Math.Max(0.001, sw.Elapsed.TotalSeconds); + Logger?.LogInformation("Measured upload speed: {SpeedF2} MB/s", v / (1024 * 1024.0)); - DirectUploadThresholdBytes = (long)speedBytesPerSec; + var target = Math.Min(Math.Max((long)Math.Ceiling((long)Math.Ceiling(Parallelism * v * 60.0 / MaxRequestsPerMinute) * Safety), MinChunkBytes), MaxChunkBytes); + + DynamicChunkSizeBytes = (int)target; + + DirectUploadThresholdBytes = (long)(target * 1.5); - DynamicChunkSizeBytes = (int)(speedBytesPerSec * 0.9); this.Logger?.LogInformation("Upload strategy initialized: DirectUploadThresholdBytes = {DirectUploadThresholdBytes}, DynamicChunkSizeBytes = {DynamicChunkSizeBytes} (calculated from speed test)", DirectUploadThresholdBytes, DynamicChunkSizeBytes); } diff --git a/DriveClient/kDriveClient/Upload.cs b/DriveClient/kDriveClient/Upload.cs index 45c02b5..74b2504 100644 --- a/DriveClient/kDriveClient/Upload.cs +++ b/DriveClient/kDriveClient/Upload.cs @@ -68,19 +68,27 @@ public async Task UploadFileChunkedAsync(KDriveFile file, this.Logger?.LogInformation("Starting chunked upload for file '{FileName}' with size {FileSize} bytes...", file.Name, file.TotalSize); var (sessionToken, uploadUrl) = await StartUploadSessionAsync(file, ct); this.Logger?.LogInformation("Upload session started with token '{SessionToken}' and URL '{UploadUrl}' for file '{FileName}'.", sessionToken, uploadUrl, file.Name); - - await Parallel.ForEachAsync(file.Chunks, new ParallelOptions - { - MaxDegreeOfParallelism = Parallelism, - CancellationToken = ct - }, async (chunk, token) => + try { + await Parallel.ForEachAsync(file.Chunks, new ParallelOptions + { + MaxDegreeOfParallelism = Parallelism, + CancellationToken = ct + }, async (chunk, token) => + { + this.Logger?.LogInformation("Uploading chunk {ChunkNumber}/{TotalChunks} for file '{FileName}'...", chunk.ChunkNumber + 1, file.Chunks.Count, file.Name); + await UploadChunkAsync(uploadUrl, sessionToken, chunk, file, token); + chunk.Clean(); + }); + + this.Logger?.LogInformation("All chunks uploaded successfully for file '{FileName}'.", file.Name); + return await FinishUploadSessionAsync(sessionToken, file.TotalChunkHash, ct); + } + catch (Exception ex) { - this.Logger?.LogInformation("Uploading chunk {ChunkNumber}/{TotalChunks} for file '{FileName}'...", chunk.ChunkNumber + 1, file.Chunks.Count, file.Name); - await UploadChunkAsync(uploadUrl, sessionToken, chunk, file, token); - }); - - this.Logger?.LogInformation("All chunks uploaded successfully for file '{FileName}'.", file.Name); - return await FinishUploadSessionAsync(sessionToken, file.TotalChunkHash, ct); + this.Logger?.LogError("Error while uploading file: {error}.", ex.Message); + await this.CancelUploadSessionRequest(sessionToken, ct); + throw; + } } /// @@ -120,10 +128,13 @@ private async Task UploadChunkAsync(string uploadUrl, string sessionToken, KDriv { this.Logger?.LogInformation("Uploading chunk {ChunkNumber}/{TotalChunks} for file '{FileName}' with size {ChunkSize} bytes...", chunk.ChunkNumber + 1, file.Chunks.Count, file.Name, chunk.ChunkSize); + var response = await SendAsync(KDriveRequestFactory.CreateChunkUploadRequest(uploadUrl, sessionToken, this.DriveId, chunk), ct); try { response = await KDriveJsonHelper.DeserializeResponseAsync(response, ct); + + chunk.Clean(); } catch (HttpRequestException ex) { From 83d634ea306c042d99a96dfaa3e74382139f8d27 Mon Sep 17 00:00:00 2001 From: anthonychaussin Date: Wed, 15 Oct 2025 11:34:40 +0200 Subject: [PATCH 4/6] Add documentation and fix tests --- DriveClient/Helpers/KDriveJsonContext.cs | 3 +- DriveClient/Helpers/KDriveJsonHelper.cs | 2 +- DriveClient/Helpers/KDriveRequestFactory.cs | 2 +- DriveClient/Models/ConflictChoice.cs | 6 +- DriveClient/Models/KDriveChunk.cs | 6 ++ DriveClient/Models/KDriveFile.cs | 11 ++- DriveClient/Models/KDriveFinishRequest.cs | 4 +- DriveClient/Models/KDriveUploadResponse.cs | 86 ++++++++++++++++++- DriveClient/kDriveClient/KDriveClient.cs | 15 ++-- DriveClient/kDriveClient/SpeedTest.cs | 3 +- DriveClient/kDriveClient/Upload.cs | 9 +- DriveClientTests/KDriveFileTests.cs | 9 +- .../kDriveClient/KDriveClientTests.cs | 4 +- 13 files changed, 130 insertions(+), 30 deletions(-) diff --git a/DriveClient/Helpers/KDriveJsonContext.cs b/DriveClient/Helpers/KDriveJsonContext.cs index f2af8ee..fbda3eb 100644 --- a/DriveClient/Helpers/KDriveJsonContext.cs +++ b/DriveClient/Helpers/KDriveJsonContext.cs @@ -1,5 +1,6 @@ using kDriveClient.Models; using kDriveClient.Models.Exceptions; + namespace kDriveClient.Helpers { /// @@ -17,4 +18,4 @@ namespace kDriveClient.Helpers public partial class KDriveJsonContext : JsonSerializerContext { } -} +} \ No newline at end of file diff --git a/DriveClient/Helpers/KDriveJsonHelper.cs b/DriveClient/Helpers/KDriveJsonHelper.cs index 467aa8c..4cd29c3 100644 --- a/DriveClient/Helpers/KDriveJsonHelper.cs +++ b/DriveClient/Helpers/KDriveJsonHelper.cs @@ -58,7 +58,7 @@ public static async Task DeserializeResponseAsync(HttpRespo try { await using var stream = await response.Content.ReadAsStreamAsync(ct); - error = await JsonSerializer.DeserializeAsync( stream, KDriveJsonContext.Default.KDriveErrorResponse, cancellationToken: ct); + error = await JsonSerializer.DeserializeAsync(stream, KDriveJsonContext.Default.KDriveErrorResponse, cancellationToken: ct); } catch { diff --git a/DriveClient/Helpers/KDriveRequestFactory.cs b/DriveClient/Helpers/KDriveRequestFactory.cs index bbc5c57..5187c72 100644 --- a/DriveClient/Helpers/KDriveRequestFactory.cs +++ b/DriveClient/Helpers/KDriveRequestFactory.cs @@ -20,7 +20,7 @@ public static HttpRequestMessage CreateUploadDirectRequest(long driveId, KDriveF var url = $"/3/drive/{driveId}/upload?" + string.Join("&", BuildUploadQueryParams(file).ToList().ConvertAll(e => $"{e.Key}={e.Value}")); var request = new HttpRequestMessage(HttpMethod.Post, url) { - Content = new StreamContent(new MemoryStream(file.Chunks.First().Content)) + Content = new StreamContent(new MemoryStream(file.Chunks.First().Content!)) }; request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); diff --git a/DriveClient/Models/ConflictChoice.cs b/DriveClient/Models/ConflictChoice.cs index af688a4..f8f4f8e 100644 --- a/DriveClient/Models/ConflictChoice.cs +++ b/DriveClient/Models/ConflictChoice.cs @@ -1,7 +1,7 @@ namespace kDriveClient.Models { /// - /// + /// /// public enum ConflictChoice { @@ -9,13 +9,15 @@ public enum ConflictChoice /// Throw an error /// Error, + /// /// Add a new version to file /// Version, + /// /// Create a new file with an automated renaming /// Rename, } -} +} \ No newline at end of file diff --git a/DriveClient/Models/KDriveChunk.cs b/DriveClient/Models/KDriveChunk.cs index b4b624c..e153bef 100644 --- a/DriveClient/Models/KDriveChunk.cs +++ b/DriveClient/Models/KDriveChunk.cs @@ -25,11 +25,17 @@ public class KDriveChunk(byte[] content, int chunkNumber, byte[] hash) : IDispos /// public byte[]? Content { get; private set; } = content; + /// + /// Frees resources used by the KDriveChunk. + /// public void Dispose() { GC.SuppressFinalize(this); } + /// + /// Deletes the content of the chunk to free memory. + /// internal void Clean() { this.Content = null; diff --git a/DriveClient/Models/KDriveFile.cs b/DriveClient/Models/KDriveFile.cs index 9c0d463..ab38f0e 100644 --- a/DriveClient/Models/KDriveFile.cs +++ b/DriveClient/Models/KDriveFile.cs @@ -42,7 +42,7 @@ public class KDriveFile : IDisposable /// /// TotalChunkHash is the SHA-256 hash of the entire file content, computed from all chunks. /// - public string TotalChunkHash { get; set; } + public string TotalChunkHash { get; set; } = string.Empty; /// /// TotalSize is the total size of the file in bytes, calculated as the sum of all chunk sizes. @@ -51,7 +51,7 @@ public long TotalSize { get { - if(totalSize == 0) + if (totalSize == 0) { totalSize = this.Chunks.Sum(c => (long)c.ChunkSize); } @@ -67,7 +67,7 @@ public long TotalSize /// /// Content is a stream representing the file's content. /// - public required Stream Content { get; init; } + public Stream Content { get; init; } /// /// In case of conflict with an existing file, it define how to manage the conflict @@ -126,10 +126,13 @@ public string ConvertConflictChoice() }; } + /// + /// Frees resources used by the KDriveFile instance. + /// public void Dispose() { this.Chunks.ForEach(c => c.Dispose()); - + GC.SuppressFinalize(this); } } diff --git a/DriveClient/Models/KDriveFinishRequest.cs b/DriveClient/Models/KDriveFinishRequest.cs index b02e2f9..834c794 100644 --- a/DriveClient/Models/KDriveFinishRequest.cs +++ b/DriveClient/Models/KDriveFinishRequest.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace kDriveClient.Models { /// @@ -13,4 +11,4 @@ public class KDriveFinishRequest [JsonPropertyName("total_chunk_hash")] public string TotalChunkHash { get; set; } = string.Empty; } -} +} \ No newline at end of file diff --git a/DriveClient/Models/KDriveUploadResponse.cs b/DriveClient/Models/KDriveUploadResponse.cs index ee9338c..5c606fa 100644 --- a/DriveClient/Models/KDriveUploadResponse.cs +++ b/DriveClient/Models/KDriveUploadResponse.cs @@ -16,11 +16,29 @@ public class KDriveUploadResponseWraper : ApiResultBase public KDriveUploadDataResponse? Data { get; set; } } + /// + /// KDrive upload data response model. + /// public class KDriveUploadDataResponse : ApiResultBase { - public KDriveUploadResponse File { get; set; } - public string Token { get; set; } + /// + /// KDrive file upload response details. + /// + public KDriveUploadResponse? File { get; set; } + + /// + /// Token for the uploaded request. + /// + public string? Token { get; set; } + + /// + /// Status of the upload operation. + /// public bool Result { get; set; } + + /// + /// Additional message regarding the upload operation. + /// public string? Message { get; set; } } @@ -73,20 +91,80 @@ public class KDriveUploadResponse : ApiResultBase /// File hash. /// public string? Hash { get; set; } + + /// + /// File or folder type. + /// public string Type { get; set; } + + /// + /// Current status of the file. + /// public string Status { get; set; } + + /// + /// Visibility of the file. + /// public string Visibility { get; set; } + + /// + /// Drive ID associated with the file. + /// public int Drive_id { get; set; } + + /// + /// Depth of the file in the directory structure. + /// public int Depth { get; set; } + + /// + /// Creator user ID. + /// public int Created_by { get; set; } + + /// + /// Timestamp when the file was created. + /// public int Created_at { get; set; } + + /// + /// Timestamp when the file was added to the drive. + /// public int Added_at { get; set; } + + /// + /// Timestamp when the file was last modified. + /// public int Last_modified_at { get; set; } + + /// + /// Timestamp when the file was last edited. + /// public int Last_modified_by { get; set; } + + /// + /// Timestamp of last file version. + /// public int Revised_at { get; set; } + + /// + /// Timestamp when the file was last updated. + /// public int Updated_at { get; set; } + + /// + /// Id of parent directory. + /// public int Parent_id { get; set; } - public string Extension_type { get; set; } - public string Scan_status { get; set; } + + /// + /// Extension type of the file. + /// + public string? Extension_type { get; set; } + + /// + /// Antivirus scan status of the file. + /// + public string? Scan_status { get; set; } } } \ No newline at end of file diff --git a/DriveClient/kDriveClient/KDriveClient.cs b/DriveClient/kDriveClient/KDriveClient.cs index 8b9faff..d339d9c 100644 --- a/DriveClient/kDriveClient/KDriveClient.cs +++ b/DriveClient/kDriveClient/KDriveClient.cs @@ -1,7 +1,6 @@ using kDriveClient.Helpers; using kDriveClient.Models; using System.Diagnostics; -using System.Net; using System.Net.Http.Headers; using System.Reflection; using System.Threading.RateLimiting; @@ -104,7 +103,7 @@ public KDriveClient(string token, long driveId, bool autoChunk, int parallelism, string version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown"; HttpClient = httpClient ?? new HttpClient { BaseAddress = new Uri("https://api.infomaniak.com") }; HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - HttpClient.DefaultRequestHeaders.UserAgent.ParseAdd("kDriveClient.NET/"+GetVersion()); + HttpClient.DefaultRequestHeaders.UserAgent.ParseAdd("kDriveClient.NET/" + GetVersion()); this.Logger?.LogInformation("KDriveClient initialized with Drive ID: {DriveId}", DriveId); if (autoChunk) @@ -118,7 +117,7 @@ public KDriveClient(string token, long driveId, bool autoChunk, int parallelism, DynamicChunkSizeBytes = 1024 * 1024; // Default to 1MB chunks this.Logger?.LogInformation("Using default chunk size: {ChunkSize} bytes", DynamicChunkSizeBytes); } - else + else { DynamicChunkSizeBytes = chunkSize.Value; this.Logger?.LogInformation("Using custom chunk size: {ChunkSize} bytes", DynamicChunkSizeBytes); @@ -187,7 +186,11 @@ private async Task SendWithErrorHandlingAsync(HttpRequestMe return await KDriveJsonHelper.DeserializeResponseAsync(response, ct); } - static string GetVersion() + /// + /// Gets the version of the assembly. + /// + /// The verstion of the assembly + private static string GetVersion() { var asm = typeof(KDriveClient).Assembly; var infoVer = asm.GetCustomAttribute()?.InformationalVersion; @@ -203,9 +206,9 @@ static string GetVersion() if (!string.IsNullOrWhiteSpace(fvi.FileVersion)) return fvi.FileVersion!; } } - catch {} + catch { } return "unknown"; } } -} +} \ No newline at end of file diff --git a/DriveClient/kDriveClient/SpeedTest.cs b/DriveClient/kDriveClient/SpeedTest.cs index 3f2fd23..f16569e 100644 --- a/DriveClient/kDriveClient/SpeedTest.cs +++ b/DriveClient/kDriveClient/SpeedTest.cs @@ -12,6 +12,7 @@ public partial class KDriveClient private const int MinChunkBytes = 100 * 1024 * 1024; private const long MaxChunkBytes = 1L * 1024 * 1024 * 1024; private const double Safety = 1.10; + /// /// Initializes the upload strategy by performing a speed test. /// @@ -81,7 +82,7 @@ private async Task InitializeUploadStrategyAsync(int? customChunkSize = null, Ca Logger?.LogInformation("Measured upload speed: {SpeedF2} MB/s", v / (1024 * 1024.0)); var target = Math.Min(Math.Max((long)Math.Ceiling((long)Math.Ceiling(Parallelism * v * 60.0 / MaxRequestsPerMinute) * Safety), MinChunkBytes), MaxChunkBytes); - + DynamicChunkSizeBytes = (int)target; DirectUploadThresholdBytes = (long)(target * 1.5); diff --git a/DriveClient/kDriveClient/Upload.cs b/DriveClient/kDriveClient/Upload.cs index 74b2504..c52da1f 100644 --- a/DriveClient/kDriveClient/Upload.cs +++ b/DriveClient/kDriveClient/Upload.cs @@ -68,15 +68,16 @@ public async Task UploadFileChunkedAsync(KDriveFile file, this.Logger?.LogInformation("Starting chunked upload for file '{FileName}' with size {FileSize} bytes...", file.Name, file.TotalSize); var (sessionToken, uploadUrl) = await StartUploadSessionAsync(file, ct); this.Logger?.LogInformation("Upload session started with token '{SessionToken}' and URL '{UploadUrl}' for file '{FileName}'.", sessionToken, uploadUrl, file.Name); - try { + try + { await Parallel.ForEachAsync(file.Chunks, new ParallelOptions { MaxDegreeOfParallelism = Parallelism, CancellationToken = ct }, async (chunk, token) => { - this.Logger?.LogInformation("Uploading chunk {ChunkNumber}/{TotalChunks} for file '{FileName}'...", chunk.ChunkNumber + 1, file.Chunks.Count, file.Name); - await UploadChunkAsync(uploadUrl, sessionToken, chunk, file, token); + this.Logger?.LogInformation("Uploading chunk {ChunkNumber}/{TotalChunks} for file '{FileName}'...", chunk.ChunkNumber + 1, file.Chunks.Count, file.Name); + await UploadChunkAsync(uploadUrl, sessionToken, chunk, file, token); chunk.Clean(); }); @@ -128,7 +129,7 @@ private async Task UploadChunkAsync(string uploadUrl, string sessionToken, KDriv { this.Logger?.LogInformation("Uploading chunk {ChunkNumber}/{TotalChunks} for file '{FileName}' with size {ChunkSize} bytes...", chunk.ChunkNumber + 1, file.Chunks.Count, file.Name, chunk.ChunkSize); - + var response = await SendAsync(KDriveRequestFactory.CreateChunkUploadRequest(uploadUrl, sessionToken, this.DriveId, chunk), ct); try { diff --git a/DriveClientTests/KDriveFileTests.cs b/DriveClientTests/KDriveFileTests.cs index 0eee323..a6697d1 100644 --- a/DriveClientTests/KDriveFileTests.cs +++ b/DriveClientTests/KDriveFileTests.cs @@ -33,7 +33,14 @@ public void SplitIntoChunksTest(int totalSize, int nbChunk) foreach (var chunk in file.Chunks) { Assert.IsFalse(string.IsNullOrWhiteSpace(chunk.ChunkHash)); - Assert.AreEqual(chunk.Content.Length, chunk.ChunkSize); + if(chunk != file.Chunks.Last()) + { + Assert.AreEqual(1000, chunk.ChunkSize); + } + else + { + Assert.AreEqual(totalSize % 1000 == 0 ? 1000 : totalSize % 1000, chunk.ChunkSize); + } } Assert.IsFalse(string.IsNullOrWhiteSpace(file.TotalChunkHash)); diff --git a/DriveClientTests/kDriveClient/KDriveClientTests.cs b/DriveClientTests/kDriveClient/KDriveClientTests.cs index 1833239..ca33ba3 100644 --- a/DriveClientTests/kDriveClient/KDriveClientTests.cs +++ b/DriveClientTests/kDriveClient/KDriveClientTests.cs @@ -13,7 +13,7 @@ public async Task UploadDirect_Should_ConstructProperRequest() { var response = new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent("{\"Result\":\"success\",\"Data\":{\"id\":123,\"name\":\"example.txt\",\"path\":\"/Private/\",\"hash\":\"test\",\"mime_type\":\"text/text\"}}") + Content = new StringContent("{\"result\":\"success\",\"data\":{\"file\":{\"id\":123,\"name\":\"example.txt\",\"path\":\"/Private/\",\"hash\":\"test\",\"mime_type\":\"text/text\"}}}") }; var handler = new FakeHttpMessageHandler(response); @@ -25,7 +25,7 @@ public async Task UploadDirect_Should_ConstructProperRequest() DirectoryPath = "/documents", Content = new MemoryStream([1, 2, 3]) }; - file.SplitIntoChunks(1000); + var result = await client.UploadAsync(file); Assert.AreEqual(123, result.Id); From b7196918bf5104459344dc9c4f2ccb238744fb0a Mon Sep 17 00:00:00 2001 From: anthonychaussin Date: Wed, 15 Oct 2025 11:45:18 +0200 Subject: [PATCH 5/6] update change log --- DriveClient/kDriveClient.csproj | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/DriveClient/kDriveClient.csproj b/DriveClient/kDriveClient.csproj index f432250..68c15a0 100644 --- a/DriveClient/kDriveClient.csproj +++ b/DriveClient/kDriveClient.csproj @@ -23,10 +23,20 @@ Native .NET logging support README.md https://github.com/anthonychaussin/DriveClient infomaniak;kDrive;wrapper;c#;DotNet - ✨ New features -- Add optional chunkSize parameter to KDriveClient constructor -- bypass speed test if custom chunk size is provided -- fix user-agent version + + ✨ New features + - Improve chunk size estimation + - Improve memory management + - Complete kDrive File Object + - Fix upload response wrapper + - fix hash computation + - bypass speed test if custom chunk size is provided + - fix user-agent version + - Add custom chunk size support and fix total_chunk_hash calculation + - Add optional chunkSize parameter to KDriveClient constructor + - Fix total_chunk_hash calculation to hash concatenated chunk hash hex strings + - Use IncrementalHash for efficient hash computation during file chunking + en LICENSE.txt True From 7e1111baf148e77cff72a3d880e80954ed96182e Mon Sep 17 00:00:00 2001 From: anthonychaussin Date: Wed, 15 Oct 2025 11:57:20 +0200 Subject: [PATCH 6/6] Fix warning --- DriveClient/Models/KDriveFile.cs | 7 ++++++- DriveClient/Models/KDriveUploadResponse.cs | 6 +++--- DriveClient/kDriveClient/KDriveClient.cs | 2 ++ DriveClientTests/kDriveClient/KDriveClientTests.cs | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/DriveClient/Models/KDriveFile.cs b/DriveClient/Models/KDriveFile.cs index ab38f0e..ef73688 100644 --- a/DriveClient/Models/KDriveFile.cs +++ b/DriveClient/Models/KDriveFile.cs @@ -67,7 +67,7 @@ public long TotalSize /// /// Content is a stream representing the file's content. /// - public Stream Content { get; init; } + public Stream? Content { get; init; } /// /// In case of conflict with an existing file, it define how to manage the conflict @@ -80,6 +80,11 @@ public long TotalSize /// Define the size of each chunk (except the last one) public void SplitIntoChunks(int chunkSize) { + if (this.Content == null) + { + throw new InvalidOperationException("Content stream is null."); + } + var buffer = new byte[chunkSize]; int chunkNumber = 0; int bytesRead; diff --git a/DriveClient/Models/KDriveUploadResponse.cs b/DriveClient/Models/KDriveUploadResponse.cs index 5c606fa..72e10f3 100644 --- a/DriveClient/Models/KDriveUploadResponse.cs +++ b/DriveClient/Models/KDriveUploadResponse.cs @@ -95,17 +95,17 @@ public class KDriveUploadResponse : ApiResultBase /// /// File or folder type. /// - public string Type { get; set; } + public required string Type { get; set; } /// /// Current status of the file. /// - public string Status { get; set; } + public required string Status { get; set; } /// /// Visibility of the file. /// - public string Visibility { get; set; } + public required string Visibility { get; set; } /// /// Drive ID associated with the file. diff --git a/DriveClient/kDriveClient/KDriveClient.cs b/DriveClient/kDriveClient/KDriveClient.cs index d339d9c..fb94cb8 100644 --- a/DriveClient/kDriveClient/KDriveClient.cs +++ b/DriveClient/kDriveClient/KDriveClient.cs @@ -1,6 +1,7 @@ using kDriveClient.Helpers; using kDriveClient.Models; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Net.Http.Headers; using System.Reflection; using System.Threading.RateLimiting; @@ -190,6 +191,7 @@ private async Task SendWithErrorHandlingAsync(HttpRequestMe /// Gets the version of the assembly. /// /// The verstion of the assembly + [RequiresAssemblyFiles("Calls System.Reflection.Assembly.Location")] private static string GetVersion() { var asm = typeof(KDriveClient).Assembly; diff --git a/DriveClientTests/kDriveClient/KDriveClientTests.cs b/DriveClientTests/kDriveClient/KDriveClientTests.cs index ca33ba3..e863908 100644 --- a/DriveClientTests/kDriveClient/KDriveClientTests.cs +++ b/DriveClientTests/kDriveClient/KDriveClientTests.cs @@ -13,7 +13,7 @@ public async Task UploadDirect_Should_ConstructProperRequest() { var response = new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent("{\"result\":\"success\",\"data\":{\"file\":{\"id\":123,\"name\":\"example.txt\",\"path\":\"/Private/\",\"hash\":\"test\",\"mime_type\":\"text/text\"}}}") + Content = new StringContent("""{"result":"success","data":{"file":{"id":123,"name":"example.txt","path":"/Private","hash":"test","mime_type":"text/text", "visibility": "private_folder", "status": "ok", "type": "file"}}}""") }; var handler = new FakeHttpMessageHandler(response);