diff --git a/Starward.sln b/Starward.sln index 50ba3a807..962dd9ac8 100644 --- a/Starward.sln +++ b/Starward.sln @@ -16,6 +16,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Starward.Launcher", "src\St EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Starward.Language", "src\Starward.Language\Starward.Language.csproj", "{5FC2380B-F424-4EAE-BDEE-C0D4D4B1C7CF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Starward.Core.ZipStreamDownload", "src\Starward.Core.ZipStreamDownload\Starward.Core.ZipStreamDownload.csproj", "{AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -92,6 +94,22 @@ Global {5FC2380B-F424-4EAE-BDEE-C0D4D4B1C7CF}.Release|x64.Build.0 = Release|Any CPU {5FC2380B-F424-4EAE-BDEE-C0D4D4B1C7CF}.Release|x86.ActiveCfg = Release|Any CPU {5FC2380B-F424-4EAE-BDEE-C0D4D4B1C7CF}.Release|x86.Build.0 = Release|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Debug|ARM64.Build.0 = Debug|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Debug|x64.ActiveCfg = Debug|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Debug|x64.Build.0 = Debug|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Debug|x86.ActiveCfg = Debug|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Debug|x86.Build.0 = Debug|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Release|Any CPU.Build.0 = Release|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Release|ARM64.ActiveCfg = Release|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Release|ARM64.Build.0 = Release|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Release|x64.ActiveCfg = Release|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Release|x64.Build.0 = Release|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Release|x86.ActiveCfg = Release|Any CPU + {AAD0ECEC-1814-4CCE-AF0D-EF1C9EB627DA}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Starward.Core.ZipStreamDownload/Exceptions/CrcVerificationFailedException.cs b/src/Starward.Core.ZipStreamDownload/Exceptions/CrcVerificationFailedException.cs new file mode 100644 index 000000000..0a2014f97 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Exceptions/CrcVerificationFailedException.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; +using Starward.Core.ZipStreamDownload.Resources; + +namespace Starward.Core.ZipStreamDownload.Exceptions; + +/// +/// 当CRC校验失败时引发的异常 +/// +public class CrcVerificationFailedException : ZipStreamDownloadException +{ + /// + /// 创建一个当CRC校验失败时引发的异常的实例。 + /// + /// 异常消息 + public CrcVerificationFailedException(string? message) : base(message) + { + } + + /// + /// 创建一个当CRC校验失败时引发的异常的实例。 + /// + /// 异常消息 + /// 引发该异常的异常 + public CrcVerificationFailedException(string? message, Exception? innerException) : base(message, innerException) + { + } + + /// + /// 使用ZipEntry的名称引发CRC校验失败时引发的异常。 + /// + /// ZipEntry的名称 + /// 当CRC校验失败时引发此异常。 + [DoesNotReturn] + internal static void ThrowByZipEntryName(string zipEntryName) + { + throw new InvalidZipEntryNameException( + string.Format(ExceptionMessages.CrcVerificationFailedExceptionMessage, zipEntryName)); + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Exceptions/FeatureNotSupportedException.cs b/src/Starward.Core.ZipStreamDownload/Exceptions/FeatureNotSupportedException.cs new file mode 100644 index 000000000..871c63ee6 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Exceptions/FeatureNotSupportedException.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; +using Starward.Core.ZipStreamDownload.Resources; + +namespace Starward.Core.ZipStreamDownload.Exceptions; + +/// +/// 当解压ZIP文件所需的功能不受支持时引发的异常。 +/// +public class FeatureNotSupportedException : ZipStreamDownloadException +{ + /// + /// 创建一个当解压ZIP文件所需的功能不受支持时引发的异常的实例。 + /// + /// 异常消息 + public FeatureNotSupportedException(string? message) : base(message) + { + } + + /// + /// 创建一个当解压ZIP文件所需的功能不受支持时引发的异常的实例。 + /// + /// 异常消息 + /// 引发该异常的异常 + public FeatureNotSupportedException(string? message, Exception? innerException) : base(message, innerException) + { + } + + /// + /// 使用原因引发解压ZIP文件所需的功能不受支持的异常。 + /// + /// 引发该异常的原因 + /// 当解压ZIP文件所需的功能不受支持时引发此异常。 + [DoesNotReturn] + internal static void ThrowByReason(string reason) + { + throw new InvalidZipEntryNameException( + string.Format(ExceptionMessages.FeatureNotSupportedExceptionMessage, reason)); + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Exceptions/InvalidZipEntryNameException.cs b/src/Starward.Core.ZipStreamDownload/Exceptions/InvalidZipEntryNameException.cs new file mode 100644 index 000000000..f42cc63c6 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Exceptions/InvalidZipEntryNameException.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; +using Starward.Core.ZipStreamDownload.Resources; + +namespace Starward.Core.ZipStreamDownload.Exceptions; + +/// +/// 当ZIP实体的名称无效时引发的异常。 +/// +public class InvalidZipEntryNameException : ZipStreamDownloadException +{ + /// + /// 创建一个当ZIP实体的名称无效时引发的异常的实例。 + /// + /// 异常消息 + public InvalidZipEntryNameException(string? message) : base(message) + { + } + + /// + /// 创建一个当ZIP实体的名称无效时引发的异常的实例。 + /// + /// 异常消息 + /// 引发该异常的异常 + public InvalidZipEntryNameException(string? message, Exception? innerException) : base(message, innerException) + { + } + + /// + /// 使用ZIP实体名称引发ZIP实体的名称无效的异常。 + /// + /// ZIP实体名称 + /// 当ZIP实体的名称无效时引发此异常。 + [DoesNotReturn] + internal static void ThrowByZipEntryName(string zipEntryName) + { + throw new InvalidZipEntryNameException( + string.Format(ExceptionMessages.ZipEntryFileNameNotFoundExceptionMessage, zipEntryName)); + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Exceptions/ThrowException.cs b/src/Starward.Core.ZipStreamDownload/Exceptions/ThrowException.cs new file mode 100644 index 000000000..de526b493 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Exceptions/ThrowException.cs @@ -0,0 +1,36 @@ +using System.Runtime.CompilerServices; +using ICSharpCode.SharpZipLib.Zip; +using Starward.Core.ZipStreamDownload.Resources; + +namespace Starward.Core.ZipStreamDownload.Exceptions; + +/// +/// 异常帮助类(当满足特定条件时抛出特定异常) +/// +internal static class ThrowException +{ + /// + /// 如果给定的目录不存在则引发找不到路径的异常。 + /// + /// 要进行检查的目录信息 + /// 当给定的路径不存在时引发此异常 + public static void ThrowDirectoryNotFoundExceptionIfDirectoryNotExists(DirectoryInfo directoryInfo) + { + if (!directoryInfo.Exists) + throw new DirectoryNotFoundException( + string.Format(ExceptionMessages.DirectoryNotFoundExceptionMessage, directoryInfo.FullName)); + } + + /// + /// 如ZipEntry不为文件时引发异常。 + /// + /// 的实例 + /// 参数名称 + /// 当参数错误时引发的异常 + public static void ThrowArgumentExceptionIfZipEntryNotIsFile(ZipEntry zipEntry, + [CallerArgumentExpression(nameof(zipEntry))] string? paramName = null) + { + if (!zipEntry.IsFile) + throw new ArgumentException(ExceptionMessages.ZipEntryNotIsFileArgumentExceptionMessage, paramName); + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Exceptions/ZipFileTestFailedException.cs b/src/Starward.Core.ZipStreamDownload/Exceptions/ZipFileTestFailedException.cs new file mode 100644 index 000000000..1caf7ee89 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Exceptions/ZipFileTestFailedException.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; +using Starward.Core.ZipStreamDownload.Resources; + +namespace Starward.Core.ZipStreamDownload.Exceptions; + +/// +/// 当ZIP文件测试失败时引发的异常。 +/// +public class ZipFileTestFailedException : ZipStreamDownloadException +{ + /// + /// 创建一个当ZIP文件测试失败时引发的异常的实例。 + /// + /// 异常消息 + public ZipFileTestFailedException(string? message) : base(message) + { + } + + /// + /// 创建一个当ZIP文件测试失败时引发的异常的实例。 + /// + /// 异常消息 + /// 引发该异常的异常 + public ZipFileTestFailedException(string? message, Exception? innerException) : base(message, innerException) + { + } + + /// + /// 根据ZIP实体名称和异常原因引发ZIP文件测试失败的异常。 + /// + /// ZIP实体名称 + /// 异常原因 + /// 当ZIP文件测试失败时引发此异常。 + [DoesNotReturn] + internal static void ThrowByZipEntryNameAndReason(string zipEntryName, string reason) + { + throw new InvalidZipEntryNameException( + string.Format(ExceptionMessages.ZipFileTestFailedExceptionMessage, zipEntryName, reason)); + } + + /// + /// 根据中心文件下载时的异常原因引发ZIP文件测试失败的异常。 + /// + /// 异常原因 + /// 当ZIP文件测试失败时引发此异常。 + [DoesNotReturn] + internal static void ThrowByReasonCentralDirectory(string reason) + { + throw new InvalidZipEntryNameException( + string.Format(ExceptionMessages.ZipFileTestFailedExceptionCentralDirectoryMessage, reason)); + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Exceptions/ZipStreamDownloadException.cs b/src/Starward.Core.ZipStreamDownload/Exceptions/ZipStreamDownloadException.cs new file mode 100644 index 000000000..7bd3754ca --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Exceptions/ZipStreamDownloadException.cs @@ -0,0 +1,24 @@ +namespace Starward.Core.ZipStreamDownload.Exceptions; + +/// +/// 当ZIP流式下载时出现错误引发的异常。 +/// +public class ZipStreamDownloadException : Exception +{ + /// + /// 创建一个当ZIP流式下载时出现错误引发的异常的实例。 + /// + /// 异常消息 + public ZipStreamDownloadException(string? message) : base(message) + { + } + + /// + /// 创建一个当ZIP流式下载时出现错误引发的异常的实例。 + /// + /// 异常消息 + /// 引发该异常的异常 + public ZipStreamDownloadException(string? message, Exception? innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Extensions/StreamByteOrderExtensions.cs b/src/Starward.Core.ZipStreamDownload/Extensions/StreamByteOrderExtensions.cs new file mode 100644 index 000000000..48e0bd28a --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Extensions/StreamByteOrderExtensions.cs @@ -0,0 +1,379 @@ +using System.Buffers; +using System.Runtime.CompilerServices; + +namespace Starward.Core.ZipStreamDownload.Extensions; + +/// +/// 按字节读取扩展 +/// +internal static class StreamByteOrderExtensions +{ + /// + /// 反转字节数组的字节顺序。 + /// + /// 字节数组 + /// 取消令牌 + /// 令牌已被请求取消。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ReverseBytes(Span bytes, CancellationToken cancellationToken = default) + { + for (var index = 0; index < bytes.Length / 2; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + bytes[bytes.Length - 1 - index] = (byte)(bytes[bytes.Length - 1 - index] ^ bytes[index]); + bytes[index] = (byte)(bytes[index] ^ bytes[bytes.Length - 1 - index]); + bytes[bytes.Length - 1 - index] = (byte)(bytes[bytes.Length - 1 - index] ^ bytes[index]); + } + } + + /// + /// 按小端序读取字节数据(异步)。 + /// + /// 的实例 + /// 要读取的字节的数量 + /// 是否进行反向读取 + /// 取消令牌 + /// 一个任务,可以获取读取的字节数据的数组。 + /// 形参的值超出范围。 + /// 发生I/O错误。 + /// 流不支持查找或读取。 + /// 在读取计数字节数之前到达流的末尾。 + /// 在流关闭后调用了方法。 + /// 令牌已被请求取消。 + private static async ValueTask ReadLittleEndianBytesAsync(this Stream stream, int count, + bool reverse = false, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); + if ((reverse && !stream.CanSeek) || !stream.CanRead) throw new NotSupportedException(); + + var buffer = ArrayPool.Shared.Rent(count); + try + { + if (reverse) + { + stream.Seek(-1, SeekOrigin.Current); + for (var index = count - 1; index >= 0; index--) + { + await stream.ReadExactlyAsync(buffer, index, 1, cancellationToken).ConfigureAwait(false); + if (index > 0) stream.Seek(-2, SeekOrigin.Current); + else stream.Seek(-1, SeekOrigin.Current); + } + } + else await stream.ReadExactlyAsync(buffer, 0, count, cancellationToken).ConfigureAwait(false); + if (!BitConverter.IsLittleEndian && !reverse || BitConverter.IsLittleEndian && reverse) + ReverseBytes(buffer, cancellationToken); + return buffer; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// 按小端序读取字节数据。 + /// + /// 的实例 + /// 要读取的字节的数量 + /// 是否进行反向读取 + /// 读取的字节数据的数组。 + /// 形参的值超出范围。 + /// 发生I/O错误。 + /// 流不支持查找或读取。 + /// 在读取计数字节数之前到达流的末尾。 + /// 在流关闭后调用了方法。 + public static byte[] ReadLittleEndianBytes(this Stream stream, int count, bool reverse = false) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); + if ((reverse && !stream.CanSeek) || !stream.CanRead) throw new NotSupportedException(); + + var buffer = new byte[count]; + if (reverse) + { + stream.Seek(-1, SeekOrigin.Current); + for (var index = count - 1; index >= 0; index--) + { + stream.ReadExactly(buffer, index, 1); + if (index > 0) stream.Seek(-2, SeekOrigin.Current); + else stream.Seek(-1, SeekOrigin.Current); + } + } + else stream.ReadExactly(buffer, 0, count); + + if (!BitConverter.IsLittleEndian && !reverse || BitConverter.IsLittleEndian && reverse) + ReverseBytes(buffer); + return buffer; + } + + /// + /// 按小端序写入字节数据(异步)。 + /// + /// 的实例 + /// 要写入的字节数据的数组 + /// 是否进行反向写入 + /// 取消令牌 + /// 一个任务。 + /// 发生I/O错误。 + /// 流不支持查找或写入。 + /// 在流关闭后调用了方法。 + /// 令牌已被请求取消。 + public static async Task WriteLittleEndianBytesAsync(this Stream stream, + ReadOnlyMemory bytes, bool reverse = false, CancellationToken cancellationToken = default) + { + if ((reverse && !stream.CanSeek) || !stream.CanWrite) throw new NotSupportedException(); + + ReadOnlyMemory bytesMemory; + if (!BitConverter.IsLittleEndian && !reverse || BitConverter.IsLittleEndian && reverse) + { + var bytesArray = bytes.ToArray(); + ReverseBytes(bytesArray, cancellationToken); + bytesMemory = bytesArray.AsMemory(); + } + else bytesMemory = bytes; + + if (reverse) + { + stream.Seek(-1, SeekOrigin.Current); + for (var index = bytesMemory.Length - 1; index >= 0; index--) + { + await stream.WriteAsync(bytesMemory.Slice(index, 1), cancellationToken).ConfigureAwait(false); + stream.Seek(-2, SeekOrigin.Current); + } + stream.Seek(1, SeekOrigin.Current); + } + else await stream.WriteAsync(bytesMemory, cancellationToken).ConfigureAwait(false); + } + + /// + /// 按小端序写入字节数据。 + /// + /// 的实例 + /// 要写入的字节数据的数组 + /// 是否进行反向写入 + /// 发生I/O错误。 + /// 流不支持查找或写入。 + /// 在流关闭后调用了方法。 + public static void WriteLittleEndianBytes(this Stream stream, + ReadOnlySpan bytes, bool reverse = false) + { + if ((reverse && !stream.CanSeek) || !stream.CanWrite) throw new NotSupportedException(); + + ReadOnlySpan bytesSpan; + if (!BitConverter.IsLittleEndian && !reverse || BitConverter.IsLittleEndian && reverse) + { + var bytesArray = bytes.ToArray(); + ReverseBytes(bytesArray); + bytesSpan = bytesArray; + } + else bytesSpan = bytes; + + if (reverse) + { + stream.Seek(-1, SeekOrigin.Current); + for (var index = bytesSpan.Length - 1; index >= 0; index--) + { + stream.Write(bytesSpan.Slice(index, 1)); + stream.Seek(-2, SeekOrigin.Current); + } + stream.Seek(1, SeekOrigin.Current); + } + else stream.Write(bytesSpan); + } + + /// + /// 跳过指定数量的字节。 + /// + /// 的实例 + /// 要调过的字节数 + /// 是否反向跳过 + /// 形参的值超出范围。 + /// 发生I/O错误。 + /// 流既不支持查找也不支持读取。 + /// 在流关闭后调用了方法。 + /// 在读取计数字节数之前到达流的末尾。 + public static void SkipBytes(this Stream stream, int count, bool reverse = false) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); + if (stream.CanSeek) stream.Seek(count * (reverse ? -1 : 1), SeekOrigin.Current); + else if (stream.CanRead) + { + var buffer = ArrayPool.Shared.Rent(count); + try + { + stream.ReadExactly(buffer, 0, count); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + + /// + /// 读取一个短整型数据。 + /// + /// 的实例 + /// 是否反向读取 + /// 读取的短整型数据 + /// 形参的值超出范围。 + /// 发生I/O错误。 + /// 流不支持查找或读取。 + /// 在读取计数字节数之前到达流的末尾。 + /// 在流关闭后调用了方法。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static short ReadShort(this Stream stream, bool reverse = false) => + BitConverter.ToInt16(ReadLittleEndianBytes(stream, sizeof(short), reverse)); + + /// + /// 读取一个无符号短整型数据。 + /// + /// 的实例 + /// 是否反向读取 + /// 读取的无符号短整型数据 + /// 形参的值超出范围。 + /// 发生I/O错误。 + /// 流不支持查找或读取。 + /// 在读取计数字节数之前到达流的末尾。 + /// 在流关闭后调用了方法。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ushort ReadUshort(this Stream stream, bool reverse = false) => + BitConverter.ToUInt16(ReadLittleEndianBytes(stream, sizeof(ushort), reverse)); + + /// + /// 读取一个整型数据。 + /// + /// 的实例 + /// 是否反向读取 + /// 读取的无符号短整型数据 + /// 形参的值超出范围。 + /// 发生I/O错误。 + /// 流不支持查找或读取。 + /// 在读取计数字节数之前到达流的末尾。 + /// 在流关闭后调用了方法。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int ReadInt(this Stream stream, bool reverse = false) => + BitConverter.ToInt32(ReadLittleEndianBytes(stream, sizeof(int), reverse)); + + /// + /// 读取一个无符号整型数据。 + /// + /// 的实例 + /// 是否反向读取 + /// 读取的无符号短整型数据 + /// 形参的值超出范围。 + /// 发生I/O错误。 + /// 流不支持查找或读取。 + /// 在读取计数字节数之前到达流的末尾。 + /// 在流关闭后调用了方法。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint ReadUint(this Stream stream, bool reverse = false) => + BitConverter.ToUInt32(ReadLittleEndianBytes(stream, sizeof(uint), reverse)); + + /// + /// 读取一个长整型数据。 + /// + /// 的实例 + /// 是否反向读取 + /// 读取的长整型数据 + /// 形参的值超出范围。 + /// 发生I/O错误。 + /// 流不支持查找或读取。 + /// 在读取计数字节数之前到达流的末尾。 + /// 在流关闭后调用了方法。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static long ReadLong(this Stream stream, bool reverse = false) => + BitConverter.ToInt64(ReadLittleEndianBytes(stream, sizeof(long), reverse)); + + /// + /// 读取一个无符号长整型数据。 + /// + /// 的实例 + /// 是否反向读取 + /// 读取的无符号长整型数据 + /// 形参的值超出范围。 + /// 发生I/O错误。 + /// 流不支持查找或读取。 + /// 在读取计数字节数之前到达流的末尾。 + /// 在流关闭后调用了方法。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ulong ReadUlong(this Stream stream, bool reverse = false) => + BitConverter.ToUInt64(ReadLittleEndianBytes(stream, sizeof(ulong), reverse)); + + /// + /// 写入一个短整型数据。 + /// + /// 的实例 + /// 要写入的短整型数据 + /// 是否反向写入 + /// 发生I/O错误。 + /// 流不支持查找或写入。 + /// 在流关闭后调用了方法。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void WriteNumber(this Stream stream, short value, bool reverse = false) => + stream.WriteLittleEndianBytes(BitConverter.GetBytes(value), reverse); + + /// + /// 写入一个无符号短整型数据。 + /// + /// 的实例 + /// 要写入的无符号短整型数据 + /// 是否反向写入 + /// 发生I/O错误。 + /// 流不支持查找或写入。 + /// 在流关闭后调用了方法。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void WriteNumber(this Stream stream, ushort value, bool reverse = false) => + stream.WriteLittleEndianBytes(BitConverter.GetBytes(value), reverse); + + /// + /// 写入一个整型数据。 + /// + /// 的实例 + /// 要写入的整型数据 + /// 是否反向写入 + /// 发生I/O错误。 + /// 流不支持查找或写入。 + /// 在流关闭后调用了方法。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void WriteNumber(this Stream stream, int value, bool reverse = false) => + stream.WriteLittleEndianBytes(BitConverter.GetBytes(value), reverse); + + /// + /// 写入一个无符号整型数据。 + /// + /// 的实例 + /// 要写入的无符号整型数据 + /// 是否反向写入 + /// 发生I/O错误。 + /// 流不支持查找或写入。 + /// 在流关闭后调用了方法。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void WriteNumber(this Stream stream, uint value, bool reverse = false) => + stream.WriteLittleEndianBytes(BitConverter.GetBytes(value), reverse); + + /// + /// 写入一个长整型数据。 + /// + /// 的实例 + /// 要写入的长整型数据 + /// 是否反向写入 + /// 发生I/O错误。 + /// 流不支持查找或写入。 + /// 在流关闭后调用了方法。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void WriteNumber(this Stream stream, long value, bool reverse = false) => + stream.WriteLittleEndianBytes(BitConverter.GetBytes(value), reverse); + + /// + /// 写入一个无符号长整型数据。 + /// + /// 的实例 + /// 要写入的无符号长整型数据 + /// 是否反向写入 + /// 发生I/O错误。 + /// 流不支持查找或写入。 + /// 在流关闭后调用了方法。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void WriteNumber(this Stream stream, ulong value, bool reverse = false) => + stream.WriteLittleEndianBytes(BitConverter.GetBytes(value), reverse); +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Extensions/StreamCopyToExtensions.cs b/src/Starward.Core.ZipStreamDownload/Extensions/StreamCopyToExtensions.cs new file mode 100644 index 000000000..f97b9f31f --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Extensions/StreamCopyToExtensions.cs @@ -0,0 +1,325 @@ +using System.Buffers; + +namespace Starward.Core.ZipStreamDownload.Extensions; + +/// +/// 反向复制和进度报告的扩展 +/// +internal static class StreamCopyToExtensions +{ + /// Validates arguments provided to the or methods. + /// The "destination" argument passed to the copy method. + /// The integer "bufferSize" argument passed to the copy method. + /// was null. + /// was not a positive value. + /// does not support writing. + /// does not support writing or reading. + private static void ValidateCopyToReverseArguments(Stream destination, int bufferSize) + { + ArgumentNullException.ThrowIfNull(destination); + + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bufferSize); + + if (destination is { CanWrite: true, CanSeek: true }) return; + if (destination.CanRead) throw new NotSupportedException(); + + throw new ObjectDisposedException(destination.GetType().Name); + } + + /// Validates arguments provided to the or methods. + /// The "destination" argument passed to the copy method. + /// The integer "bufferSize" argument passed to the copy method. + /// was null. + /// was not a positive value. + /// does not support writing. + /// does not support writing or reading. + private static void ValidateCopyToArguments(Stream destination, int bufferSize) + { + ArgumentNullException.ThrowIfNull(destination); + + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bufferSize); + + if (destination.CanWrite) return; + if (destination.CanRead) throw new NotSupportedException(); + + throw new ObjectDisposedException(destination.GetType().Name); + } + + private static int GetCopyBufferSize(Stream stream) + { + // This value was originally picked to be the largest multiple of 4096 that is still smaller than the large object heap threshold (85K). + // The CopyTo{Async} buffer is short-lived and is likely to be collected at Gen0, and it offers a significant improvement in Copy + // performance. Since then, the base implementations of CopyTo{Async} have been updated to use ArrayPool, which will end up rounding + // this size up to the next power of two (131,072), which will by default be on the large object heap. However, most of the time + // the buffer should be pooled, the LOH threshold is now configurable and thus may be different than 85K, and there are measurable + // benefits to using the larger buffer size. So, for now, this value remains. + const int DefaultCopyBufferSize = 81920; + + var bufferSize = DefaultCopyBufferSize; + + if (!stream.CanSeek) return bufferSize; + var length = stream.Length; + var position = stream.Position; + if (length <= position) // Handles negative overflows + { + // There are no bytes left in the stream to copy. + // However, because CopyTo{Async} is virtual, we need to + // ensure that any override is still invoked to provide its + // own validation, so we use the smallest legal buffer size here. + bufferSize = 1; + } + else + { + var remaining = length - position; + if (remaining > 0) + { + // In the case of a positive overflow, stick to the default size + bufferSize = (int)Math.Min(bufferSize, remaining); + } + } + + return bufferSize; + } + + /// + /// Asynchronously reads the bytes from the current stream and writes them reverse to another stream. Both streams positions are advanced by the number of bytes copied. + /// + /// Instance of . + /// The stream to which the contents of the current stream will be copied. + /// The size, in bytes, of the buffer. This value must be greater than zero. The default size is 81920. + /// progress updates report. + /// The token to monitor for cancellation requests. The default value is None. + /// A task that represents the asynchronous copy operation. + /// + /// + /// destination is null. + /// buffersize is negative or zero. + /// Either the current stream or the destination stream is disposed. + /// The current stream does not support reading, or the destination stream does not support writing. + public static Task CopyToReverseAsync(this Stream stream, Stream destination, int bufferSize, + IProgress? progress = null, CancellationToken cancellationToken = default) + { + ValidateCopyToReverseArguments(destination, bufferSize); + if (!stream.CanRead) + { + if (stream.CanWrite) throw new NotSupportedException(); + + throw new ObjectDisposedException(stream.GetType().Name); + } + + return Core(stream, destination, bufferSize, progress, cancellationToken); + + static async Task Core(Stream source, Stream destination, int bufferSize, IProgress? progress, + CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(bufferSize); + try + { + var count = 0L; + int bytesRead; + while ((bytesRead = await source.ReadAsync(buffer, cancellationToken) + .ConfigureAwait(false)) != 0) + { + destination.Seek(-bytesRead, SeekOrigin.Current); + for (var index = bytesRead - 1; index >= 0; index--) + await destination.WriteAsync(buffer.AsMemory().Slice(index, 1), + cancellationToken).ConfigureAwait(false); + destination.Seek(-bytesRead, SeekOrigin.Current); + count += bytesRead; + progress?.Report(count); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + + /// + /// Asynchronously reads the bytes from the current stream and writes them reverse to another stream. Both streams positions are advanced by the number of bytes copied. + /// + /// Instance of . + /// The stream to which the contents of the current stream will be copied. + /// The size, in bytes, of the buffer. This value must be greater than zero. The default size is 81920. + /// The token to monitor for cancellation requests. The default value is None. + /// A task that represents the asynchronous copy operation. + /// + /// + /// destination is null. + /// buffersize is negative or zero. + /// Either the current stream or the destination stream is disposed. + /// The current stream does not support reading, or the destination stream does not support writing. + public static Task CopyToReverseAsync(this Stream stream, Stream destination, + int bufferSize, CancellationToken cancellationToken) => + CopyToReverseAsync(stream, destination, bufferSize, null, cancellationToken); + + /// + /// Asynchronously reads the bytes from the current stream and writes them reverse to another stream. Both streams positions are advanced by the number of bytes copied. + /// + /// Instance of . + /// The stream to which the contents of the current stream will be copied. + /// progress updates report. + /// The token to monitor for cancellation requests. The default value is None. + /// A task that represents the asynchronous copy operation. + /// + /// + /// destination is null. + /// buffersize is negative or zero. + /// Either the current stream or the destination stream is disposed. + /// The current stream does not support reading, or the destination stream does not support writing. + public static Task CopyToReverseAsync(this Stream stream, Stream destination, + IProgress? progress = null, + CancellationToken cancellationToken = default) => + CopyToReverseAsync(stream, destination, GetCopyBufferSize(stream), progress, cancellationToken); + + /// + /// Asynchronously reads the bytes from the current stream and writes them reverse to another stream. Both streams positions are advanced by the number of bytes copied. + /// + /// Instance of . + /// The stream to which the contents of the current stream will be copied. + /// The token to monitor for cancellation requests. The default value is None. + /// A task that represents the asynchronous copy operation. + /// + /// + /// destination is null. + /// buffersize is negative or zero. + /// Either the current stream or the destination stream is disposed. + /// The current stream does not support reading, or the destination stream does not support writing. + public static Task CopyToReverseAsync(this Stream stream, Stream destination, + CancellationToken cancellationToken) => + CopyToReverseAsync(stream, destination, null, cancellationToken); + + /// + /// Asynchronously reads the bytes from the current stream and writes them reverse to another stream. Both streams positions are advanced by the number of bytes copied. + /// + /// Instance of . + /// The stream to which the contents of the current stream will be copied. + /// The size, in bytes, of the buffer. This value must be greater than zero. The default size is 81920. + /// + /// + /// destination is null. + /// buffersize is negative or zero. + /// Either the current stream or the destination stream is disposed. + /// The current stream does not support reading, or the destination stream does not support writing. + public static void CopyToReverse(this Stream stream, Stream destination, int bufferSize) + { + ValidateCopyToReverseArguments(destination, bufferSize); + if (!stream.CanRead) + { + if (stream.CanWrite) throw new NotSupportedException(); + + throw new ObjectDisposedException(stream.GetType().Name); + } + + Core(stream, destination, bufferSize); + return; + + static void Core(Stream source, Stream destination, int bufferSize) + { + var buffer = ArrayPool.Shared.Rent(bufferSize); + try + { + int bytesRead; + while ((bytesRead = source.Read(buffer)) != 0) + { + destination.Seek(-bytesRead, SeekOrigin.Current); + for (var index = bytesRead - 1; index >= 0; index--) + destination.Write(buffer.AsSpan().Slice(index, 1)); + destination.Seek(-bytesRead, SeekOrigin.Current); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + + /// + /// Asynchronously reads the bytes from the current stream and writes them reverse to another stream. Both streams positions are advanced by the number of bytes copied. + /// + /// Instance of . + /// The stream to which the contents of the current stream will be copied. + /// + /// + /// destination is null. + /// buffersize is negative or zero. + /// Either the current stream or the destination stream is disposed. + /// The current stream does not support reading, or the destination stream does not support writing. + public static void CopyToReverse(this Stream stream, Stream destination) => + CopyToReverse(stream, destination, GetCopyBufferSize(stream)); + + /// + /// Asynchronously reads the bytes from the current stream and writes them to another stream. Both streams positions are advanced by the number of bytes copied. + /// + /// Instance of . + /// The stream to which the contents of the current stream will be copied. + /// The size, in bytes, of the buffer. This value must be greater than zero. The default size is 81920. + /// progress updates report. + /// The token to monitor for cancellation requests. The default value is None. + /// A task that represents the asynchronous copy operation. + /// + /// + /// destination is null. + /// buffersize is negative or zero. + /// Either the current stream or the destination stream is disposed. + /// The current stream does not support reading, or the destination stream does not support writing. + public static Task CopyToAsync(this Stream stream, Stream destination, int bufferSize, + IProgress? progress, + CancellationToken cancellationToken = default) + { + ValidateCopyToArguments(destination, bufferSize); + if (!stream.CanRead) + { + if (stream.CanWrite) throw new NotSupportedException(); + + throw new ObjectDisposedException(stream.GetType().Name); + } + + return Core(stream, destination, bufferSize, progress, cancellationToken); + + static async Task Core(Stream source, Stream destination, int bufferSize, + IProgress? progress, + CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(bufferSize); + try + { + var count = 0L; + int bytesRead; + while ((bytesRead = await source.ReadAsync(buffer, cancellationToken) + .ConfigureAwait(false)) != 0) + { + await destination.WriteAsync(buffer.AsMemory()[..bytesRead], cancellationToken) + .ConfigureAwait(false); + count += bytesRead; + progress?.Report(count); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + + /// + /// Asynchronously reads the bytes from the current stream and writes them to another stream. Both streams positions are advanced by the number of bytes copied. + /// + /// Instance of . + /// The stream to which the contents of the current stream will be copied. + /// progress updates report. + /// The token to monitor for cancellation requests. The default value is None. + /// A task that represents the asynchronous copy operation. + /// + /// + /// destination is null. + /// buffersize is negative or zero. + /// Either the current stream or the destination stream is disposed. + /// The current stream does not support reading, or the destination stream does not support writing. + public static Task CopyToAsync(this Stream stream, Stream destination, + IProgress? progress, + CancellationToken cancellationToken = default) => + CopyToAsync(stream, destination, GetCopyBufferSize(stream), progress, cancellationToken); +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Extensions/UriFileNameExtensions.cs b/src/Starward.Core.ZipStreamDownload/Extensions/UriFileNameExtensions.cs new file mode 100644 index 000000000..de6d478f0 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Extensions/UriFileNameExtensions.cs @@ -0,0 +1,23 @@ +using System.Web; + +namespace Starward.Core.ZipStreamDownload.Extensions; + +/// +/// 文件名扩展 +/// +internal static class UriFileNameExtensions +{ + /// + /// 获取URI中的文件名。 + /// + /// URI对象 + /// URI中的文件名 + /// 此实例表示相对URI,此属性仅对绝对URI有效。 + public static string? GetFileName(this Uri uri) + { + var absolutePath = HttpUtility.UrlDecode(uri.AbsolutePath); + var absolutePathLastSplit = absolutePath.LastIndexOf('/'); + if (absolutePathLastSplit == -1) return absolutePath; + return absolutePathLastSplit == absolutePath.Length - 1 ? null : absolutePath[(absolutePathLastSplit + 1)..]; + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Extensions/ZipEntryFileNameExtensions.cs b/src/Starward.Core.ZipStreamDownload/Extensions/ZipEntryFileNameExtensions.cs new file mode 100644 index 000000000..edcd52829 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Extensions/ZipEntryFileNameExtensions.cs @@ -0,0 +1,38 @@ +using ICSharpCode.SharpZipLib.Zip; + +namespace Starward.Core.ZipStreamDownload.Extensions; + +/// +/// 文件名称扩展 +/// +internal static class ZipEntryFileNameExtensions +{ + /// + /// 获取文件名称。 + /// + /// 的实例 + /// 文件名称的字符串 + /// 形参不是文件类型的实体。 + public static string? GetFileName(this ZipEntry entry) + { + if (!entry.IsFile) throw new InvalidOperationException(); + var cleanName = ZipEntry.CleanName(entry.Name); + var absolutePathLastSplit = cleanName.LastIndexOf('/'); + if (absolutePathLastSplit == -1) return cleanName; + return absolutePathLastSplit == cleanName.Length - 1 ? null : cleanName[(absolutePathLastSplit + 1)..]; + } + + /// + /// 获取文件目录。 + /// + /// 的实例 + /// 文件名称的字符串 + /// 形参不是文件类型的实体。 + public static string? GetFileDirectory(this ZipEntry entry) + { + if (!entry.IsFile) throw new InvalidOperationException(); + var cleanName = ZipEntry.CleanName(entry.Name); + var absolutePathLastSplit = cleanName.LastIndexOf('/'); + return absolutePathLastSplit == -1 ? null : cleanName[..absolutePathLastSplit]; + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Extensions/ZipFileGetEntriesExtensions.cs b/src/Starward.Core.ZipStreamDownload/Extensions/ZipFileGetEntriesExtensions.cs new file mode 100644 index 000000000..48ee730bd --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Extensions/ZipFileGetEntriesExtensions.cs @@ -0,0 +1,63 @@ +using ICSharpCode.SharpZipLib.Zip; + +namespace Starward.Core.ZipStreamDownload.Extensions; + +/// +/// 获取实体列表扩展 +/// +internal static class ZipFileGetEntriesExtensions +{ + /// + /// 获取目录类型的的列表。 + /// + /// 的实例。 + /// 取消令牌 + /// 一个任务,可以获取的列表。 + /// 令牌已被请求取消。 + public static Task> GetDirectoryEntriesAsync(this ZipFile zipFile, + CancellationToken cancellationToken = default) + { + var entries = new List(); + foreach (ZipEntry entry in zipFile) + { + cancellationToken.ThrowIfCancellationRequested(); + if (entry.IsDirectory) entries.Add(entry); + } + return Task.FromResult(entries); + } + + /// + /// 获取目录类型的的列表。 + /// + /// 的实例。 + /// 的列表。 + public static List GetDirectoryEntries(this ZipFile zipFile) + => GetDirectoryEntriesAsync(zipFile).GetAwaiter().GetResult(); + + /// + /// 获取文件类型的的列表。 + /// + /// 的实例。 + /// 取消令牌 + /// 一个任务,可以获取的列表。 + /// 令牌已被请求取消。 + public static Task> GetFileEntriesAsync(this ZipFile zipFile, + CancellationToken cancellationToken = default) + { + var entries = new List(); + foreach (ZipEntry entry in zipFile) + { + cancellationToken.ThrowIfCancellationRequested(); + if (entry.IsFile) entries.Add(entry); + } + return Task.FromResult(entries); + } + + /// + /// 获取文件类型的的列表。 + /// + /// 的实例。 + /// 的列表。 + public static List GetFileEntries(this ZipFile zipFile) + => GetFileEntriesAsync(zipFile).GetAwaiter().GetResult(); +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/FastZipStreamDownload.partial.cs b/src/Starward.Core.ZipStreamDownload/FastZipStreamDownload.partial.cs new file mode 100644 index 000000000..a69e72f39 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/FastZipStreamDownload.partial.cs @@ -0,0 +1,432 @@ +using System.Collections.Concurrent; +using ICSharpCode.SharpZipLib.Zip; +using Starward.Core.ZipStreamDownload.Exceptions; +using Starward.Core.ZipStreamDownload.Extensions; +using Starward.Core.ZipStreamDownload.Http.Exceptions; + +namespace Starward.Core.ZipStreamDownload; + +/// +/// ZIP流式解压类 +/// +/// 解压目标文件夹信息对象 +/// 解压临时文件夹信息对象 +public partial class FastZipStreamDownload(DirectoryInfo targetDirectoryInfo, DirectoryInfo tempDirectoryInfo) +{ + /// + /// 默认ZIP文件的文件名 + /// + private const string DefaultZipFileName = "temp.zip"; + + /// + /// 中央目录数据文件的后缀名 + /// + private const string CentralDirectoryDataFileNameExtension = "zipcdr"; + + /// + /// 进度改变报告接口 + /// + public IProgress? Progress { get; set; } + + /// + /// 用于解压数据的目标文件夹信息对象 + /// + public DirectoryInfo TargetDirectoryInfo { get; set; } = targetDirectoryInfo; + + /// + /// 用于解压数据的目标文件夹路径 + /// + public string TargetDirectoryPath + { + get => TargetDirectoryInfo.FullName; + set => TargetDirectoryInfo = new DirectoryInfo(value); + } + + /// + /// 用于存放中央目录文件和Zip文件的临时文件夹对象 + /// 可与解压文件夹相同 + /// + public DirectoryInfo TempDirectoryInfo { get; set; } = tempDirectoryInfo; + + /// + /// 用于存放中央目录文件和Zip文件的临时文件夹路径 + /// 可与解压文件夹相同 + /// + public string TempDirectoryPath + { + get => TempDirectoryInfo.FullName; + set => TempDirectoryInfo = new DirectoryInfo(value); + } + + /// + /// 文件解压完成后是否进行CRC32校验 + /// + public bool CheckCrcExtracted { get; set; } = true; + + /// + /// 文件验证时是否进行CRC32校验 + /// + public bool CheckCrcVerifyingExistingFile { get; set; } = true; + + /// + /// 文件验证时是否进行创建日期和修改日期校验 + /// + public bool CheckDateTimeVerifyingExistingFile { get; set; } = false; + + /// + /// 是否在解压完成后自动删除中央目录文件 + /// + public bool AutoDeleteCentralDirectoryDataFile { get; set; } = true; + + /// + /// 是否开启全流式下载 + /// 更节省硬盘空间,不支持单文件断点续传 + /// + public bool EnableFullStreamDownload { get; set; } + + /// + /// 可允许的最大验证线程数 + /// 取值范围(0,30),默认为CPU线程数 + /// + public int ExistingFileVerifyThreadCount + { + get => _existingFileVerifyThreadCount; + set + { + ArgumentOutOfRangeException.ThrowIfLessThan(value, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 30); + _existingFileVerifyThreadCount = value; + } + } + + /// + /// (内部)可允许的最大验证线程数 + /// + private int _existingFileVerifyThreadCount = Math.Max(Environment.ProcessorCount, 30); + + /// + /// 可允许的最大下载线程数 + /// 取值范围(0,20),默认10 + /// + public int DownloadThreadCount + { + get => _downloadThreadCount; + set + { + ArgumentOutOfRangeException.ThrowIfLessThan(value, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 20); + _downloadThreadCount = value; + } + } + + /// + /// (内部)可允许的最大下载线程数 + /// + private int _downloadThreadCount = 10; + + /// + /// 可允许的最大解压和CRC校验线程数 + /// 取值范围(0,30),默认为CPU线程数 + /// + public int ExtractAndCrcVerifyThreadCount + { + get => _extractAndCrcVerifyThreadCount; + set + { + ArgumentOutOfRangeException.ThrowIfLessThan(value, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 30); + _extractAndCrcVerifyThreadCount = value; + } + } + + /// + /// (内部)可允许的最大解压和CRC校验线程数 + /// + private int _extractAndCrcVerifyThreadCount = Math.Max(Environment.ProcessorCount, 30); + + /// + /// 当前的实体异常列表 + /// + public IReadOnlyDictionary EntriesExceptionDictionary => _entriesExceptionDictionary; + + /// + /// (内部)ZIP文件下载是否正在执行 + /// + private int _downloadZipFileIsRunning; + + /// + /// (内部)当前的实体异常列表 + /// + private readonly ConcurrentDictionary _entriesExceptionDictionary = new(); + + /// + /// 创建一个ZIP流式解压类的实例 + /// + /// 解压目标文件夹路径 + /// 解压临时文件夹路径 + public FastZipStreamDownload(string targetDirectoryPath, string tempDirectoryPath) : + this(new DirectoryInfo(targetDirectoryPath), new DirectoryInfo(tempDirectoryPath)) + { + } + + /// + /// 创建一个ZIP流式解压类的实例 + /// + /// 解压目标文件夹信息对象 + /// 临时文件夹路径和目标文件夹路径相同 + public FastZipStreamDownload(DirectoryInfo targetDirectoryInfo) : + this(targetDirectoryInfo, targetDirectoryInfo) + { + } + + /// + /// 创建一个ZIP流式解压类的实例 + /// + /// 解压目标文件夹路径 + /// 临时文件夹路径和目标文件夹路径相同 + public FastZipStreamDownload(string targetDirectorPath) : + this(new DirectoryInfo(targetDirectorPath)) + { + } + + /// + /// 下载并获取的实例,使用该实例可获取实体文件的列表。 + /// + /// 的实例 + /// 取消令牌 + /// 一个任务,返回一个的实例。 + private async Task GetZipFileCentralDirectoryDataFileAsync(IZipFileDownloadFactory zipFileDownloadFactory, + CancellationToken cancellationToken = default) + { + //参数校验 + ThrowException.ThrowDirectoryNotFoundExceptionIfDirectoryNotExists(TargetDirectoryInfo); + ThrowException.ThrowDirectoryNotFoundExceptionIfDirectoryNotExists(TempDirectoryInfo); + + //不允许执行多次 + if (Interlocked.Increment(ref _downloadZipFileIsRunning) > 1) + { + Interlocked.Decrement(ref _downloadZipFileIsRunning); + throw new InvalidOperationException(); + } + + _entriesExceptionDictionary.Clear(); + + try + { + var result = await CoreAsync().ConfigureAwait(false); + ProgressReport(ProcessingStageEnum.None, true); //报告全局进度 + return result; + } + finally + { + Interlocked.Decrement(ref _downloadZipFileIsRunning); + } + + async Task CoreAsync() + { + var zipFileName = + (zipFileDownloadFactory.ZipFileUri == null ? null : zipFileDownloadFactory.ZipFileUri.GetFileName()) ?? + DefaultZipFileName; //如果找不到URL中的文件名,则默认为temp.zip。 + + var centralDirectoryDataFileInfo = + new FileInfo(Path.Join(TempDirectoryInfo.FullName, + $"{zipFileName}.{CentralDirectoryDataFileNameExtension}")); + + var zipFileDownload = zipFileDownloadFactory.GetInstance(); + + Exception? exception = null; + try + { + return await DownloadAndOpenCentralDirectoryDataFileAsync(zipFileDownload, + centralDirectoryDataFileInfo, (processingStage, completed, + progress, downloadByteCount) => ProgressReport(processingStage, completed, + progress, downloadByteCount) //报告实体进度 + , cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + exception = e; + centralDirectoryDataFileInfo.Delete(); //删除中央目录文件,下次重新获取 + throw; + } + finally + { + ProgressReport(ProcessingStageEnum.None, true, exception: exception); + } + } + } + + /// + /// 获取一个的列表,该列表表示一个文件实体的列表。 + /// + /// 的实例 + /// 取消令牌 + /// 一个任务,返回一个的列表。 + public async Task> GetZipFileFileEntriesAsync(IZipFileDownloadFactory zipFileDownloadFactory, + CancellationToken cancellationToken = default) + { + using var centralDirectoryDataFile = + await GetZipFileCentralDirectoryDataFileAsync(zipFileDownloadFactory, cancellationToken); + return await centralDirectoryDataFile.GetFileEntriesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// 获取一个的列表,该列表表示一个目录实体的列表。 + /// + /// 的实例 + /// 取消令牌 + /// 一个任务,返回一个的列表。 + public async Task> GetZipFileDirectoryEntriesAsync(IZipFileDownloadFactory zipFileDownloadFactory, + CancellationToken cancellationToken = default) + { + using var centralDirectoryDataFile = + await GetZipFileCentralDirectoryDataFileAsync(zipFileDownloadFactory, cancellationToken); + return await centralDirectoryDataFile.GetDirectoryEntriesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// 开始下载ZIP并解压文件(异步) + /// + /// 的实例 + /// 是否对下载好的文件进行解压(只对半流式下载模式生效) + /// 需要下载或忽略(由决定)的文件列表,为空下载全部文件 + /// 在进行文件名比较时是包含还是忽略 + /// 在进行文件名比较时是否忽略大小写 + /// 取消令牌 + /// 一个任务。 + public async Task DownloadZipFileAsync(IZipFileDownloadFactory zipFileDownloadFactory, bool extractFiles = true, + List? downloadFiles = null, bool ignoreFiles = false, bool ignoreCase = false, + CancellationToken cancellationToken = default) + { + //参数校验 + ThrowException.ThrowDirectoryNotFoundExceptionIfDirectoryNotExists(TargetDirectoryInfo); + ThrowException.ThrowDirectoryNotFoundExceptionIfDirectoryNotExists(TempDirectoryInfo); + + //不允许执行多次 + if (Interlocked.Increment(ref _downloadZipFileIsRunning) > 1) + { + Interlocked.Decrement(ref _downloadZipFileIsRunning); + throw new InvalidOperationException(); + } + + _entriesExceptionDictionary.Clear(); + + try + { + await CoreAsync().ConfigureAwait(false); + ProgressReport(ProcessingStageEnum.None, true); //报告全局进度 + } + finally + { + Interlocked.Decrement(ref _downloadZipFileIsRunning); + } + return; + + async Task CoreAsync() + { + var zipFileName = + (zipFileDownloadFactory.ZipFileUri == null ? null : zipFileDownloadFactory.ZipFileUri.GetFileName()) ?? + DefaultZipFileName; //如果找不到URL中的文件名,则默认为temp.zip。 + + var centralDirectoryDataFileInfo = + new FileInfo(Path.Join(TempDirectoryInfo.FullName, + $"{zipFileName}.{CentralDirectoryDataFileNameExtension}")); + + var zipFileDownload = zipFileDownloadFactory.GetInstance(); + + //下载中央目录文件 + ZipFile centralDirectoryDataFile; + try + { + centralDirectoryDataFile = + await DownloadAndOpenCentralDirectoryDataFileAsync(zipFileDownload, centralDirectoryDataFileInfo, + (processingStage, completed, + progress, downloadByteCount) => ProgressReport(processingStage, completed, + progress, downloadByteCount) //报告实体进度 + , cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + centralDirectoryDataFileInfo.Delete(); //删除中央目录文件,下次重新获取 + ProgressReport(ProcessingStageEnum.DownloadingCentralDirectoryDataFile, true, exception: e); + throw; + } + + //创建临时目录和解压目录结构 + var directoryEntries = + await centralDirectoryDataFile.GetDirectoryEntriesAsync(cancellationToken).ConfigureAwait(false); + ProgressReport(ProcessingStageEnum.CreatingDirectory, false, entries: directoryEntries); //报告全局进度 + foreach (var entry in directoryEntries) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + CreateDirectoryByEntry(entry, extractFiles, (processingStage, completed) => + { + ProgressReport(processingStage, completed, entry: entry); //报告实体进度 + }); + ProgressReport(ProcessingStageEnum.None, true, entry: entry); //报告实体进度 + } + catch (Exception e) + { + ProgressReport(ProcessingStageEnum.None, true, exception: e, entry: entry); //报告实体进度 + _entriesExceptionDictionary[entry] = e; + + if (e is OperationCanceledException or TaskCanceledException) throw; + } + } + + ProgressReport(ProcessingStageEnum.CreatingDirectory, true, entries: directoryEntries); //报告全局进度 + + //下载和解压文件 + var fileEntries = + await centralDirectoryDataFile.GetFileEntriesAsync(cancellationToken).ConfigureAwait(false); + if (downloadFiles is { Count: > 0 }) + { + var stringComparison = ignoreCase + ? StringComparison.InvariantCultureIgnoreCase + : StringComparison.InvariantCulture; + if (ignoreFiles) + fileEntries = fileEntries.Where( + e => downloadFiles.All(f => !f.Equals(ZipEntry.CleanName(e.Name), stringComparison))).ToList(); + else + fileEntries = fileEntries.Where( + e => downloadFiles.Any(f => f.Equals(ZipEntry.CleanName(e.Name), stringComparison))).ToList(); + } + + ProgressReport(ProcessingStageEnum.DownloadingAndExtractingFile, false, entries: fileEntries); //报告全局进度 + + fileEntries.ForEach(AddEntryTask); + try + { + await WaitExecuteEntryTasksAsync(zipFileDownloadFactory, extractFiles, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception e) + when (e is HttpFileModifiedDuringPartialDownload || + (e is AggregateException exception && exception.InnerExceptions.Any( + ie => ie is HttpFileModifiedDuringPartialDownload))) + { + //文件在下载过程中被修改了 + centralDirectoryDataFile.Close(); + centralDirectoryDataFileInfo.Delete(); //删除中央目录文件,下次重新获取 + ProgressReport(ProcessingStageEnum.DownloadingAndExtractingFile, true, entries: fileEntries, + exception: e); //报告全局进度 + throw; + } + catch (Exception e) + { + ProgressReport(ProcessingStageEnum.DownloadingAndExtractingFile, true, entries: fileEntries, + exception: e); //报告全局进度 + throw; + } + + if (_entriesExceptionDictionary.IsEmpty + && !cancellationToken.IsCancellationRequested && AutoDeleteCentralDirectoryDataFile) + { + centralDirectoryDataFile.Close(); + centralDirectoryDataFileInfo.Delete(); + } + } + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/FastZipStreamDownloadPrivateMethod.partial.cs b/src/Starward.Core.ZipStreamDownload/FastZipStreamDownloadPrivateMethod.partial.cs new file mode 100644 index 000000000..3cde31034 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/FastZipStreamDownloadPrivateMethod.partial.cs @@ -0,0 +1,411 @@ +using System.Buffers; +using ICSharpCode.SharpZipLib.Checksum; +using ICSharpCode.SharpZipLib.Zip; +using Starward.Core.ZipStreamDownload.Exceptions; +using Starward.Core.ZipStreamDownload.Extensions; + +namespace Starward.Core.ZipStreamDownload; + +/// +/// ZIP流式解压类 +/// +public partial class FastZipStreamDownload +{ + private async Task FileVerify(EntryTaskData entryTaskData, CancellationToken cancellationToken = default) + { + var entry = entryTaskData.Entry; + var extractedFileInfo = entryTaskData.ExtractedFileInfo; + + + if (!extractedFileInfo.Exists) return true; + var verifySuccess = false; + ProgressStageChangeCallback(false); + if (extractedFileInfo.Length == entry.Size && + (!CheckDateTimeVerifyingExistingFile || + (extractedFileInfo.CreationTimeUtc == entry.DateTime && + extractedFileInfo.LastWriteTimeUtc == entry.DateTime))) + { + if (CheckCrcVerifyingExistingFile) + { + var fileLength = extractedFileInfo.Length; + ProgressStageChangeCallback(false, 0, fileLength); + var bytesCount = 0L; + if (entry.Crc == await GetCrcAsync(extractedFileInfo, new Progress(count => + { + bytesCount = count; + ProgressStageChangeCallback(false, count, fileLength); + }), cancellationToken).ConfigureAwait(false)) + verifySuccess = true; + ProgressStageChangeCallback(true, bytesCount, fileLength); + } + else + { + verifySuccess = true; + ProgressStageChangeCallback(true); + } + } + if (!verifySuccess) extractedFileInfo.Delete(); + return !verifySuccess; + + void ProgressStageChangeCallback(bool completed, long? progress = null, long? byteCount = null) + { + ProgressReport(ProcessingStageEnum.VerifyingExistingFile, + completed, progress, byteCount, entry: entry); //报告实体进度 + } + } + + private async Task EntryDownloadStream(ZipFileDownload zipFileDownload, EntryTaskData entryTaskData, + CancellationToken cancellationToken = default) + { + var entry = entryTaskData.Entry; + var extractedFileTempInfo = entryTaskData.ExtractedFileTempInfo; + + + long downloadBytesCompleted = 0; + long? downloadBytesTotal = null; + ProgressStageChangeCallback(false, downloadBytesCompleted, downloadBytesTotal); + extractedFileTempInfo.Delete(); + var extractedFileTempWriteStream = extractedFileTempInfo.Open(new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + PreallocationSize = entry.Size, + Options = FileOptions.WriteThrough + }); + await using var _ = extractedFileTempWriteStream.ConfigureAwait(false); + try + { + var inputStream = await zipFileDownload.GetInputStreamAsync(entry, + new Progress(args => + { + downloadBytesCompleted = args.DownloadBytesCompleted; + downloadBytesTotal = args.DownloadBytesTotal; + ProgressStageChangeCallback(false, downloadBytesCompleted, downloadBytesTotal); + }), cancellationToken).ConfigureAwait(false); + await using var ___ = inputStream.ConfigureAwait(false); + await inputStream.CopyToAsync(extractedFileTempWriteStream, cancellationToken).ConfigureAwait(false); + await extractedFileTempWriteStream.FlushAsync(cancellationToken).ConfigureAwait(false); + extractedFileTempInfo.CreationTimeUtc = extractedFileTempInfo.LastWriteTimeUtc = entry.DateTime; + } + catch + { + await extractedFileTempWriteStream.DisposeAsync().ConfigureAwait(false); + extractedFileTempInfo.Delete(); + throw; + } + + ProgressStageChangeCallback(true, downloadBytesCompleted, downloadBytesTotal); + return; + + + void ProgressStageChangeCallback(bool completed, long? progress = null, long? byteCount = null) + { + ProgressReport(ProcessingStageEnum.StreamExtractingFile, + completed, progress, byteCount, entry: entry); //报告实体进度 + } + } + + private async Task EntryDownloadCompressedFile(ZipFileDownload zipFileDownload, EntryTaskData entryTaskData, + CancellationToken cancellationToken = default) + { + var entry = entryTaskData.Entry; + var compressedFileInfo = entryTaskData.CompressedFileInfo; + + + long downloadBytesCompleted = 0; + long? downloadBytesTotal = null; + ProgressStageChangeCallback(false, downloadBytesCompleted, downloadBytesTotal); + var compressedFileStream = compressedFileInfo.Open(FileMode.OpenOrCreate, FileAccess.ReadWrite); + try + { + await zipFileDownload.GetEntryZipFileAsync(entry, compressedFileInfo, + new Progress(args => + { + downloadBytesCompleted = args.DownloadBytesCompleted; + downloadBytesTotal = args.DownloadBytesTotal; + ProgressStageChangeCallback(false, downloadBytesCompleted, downloadBytesTotal); + }), cancellationToken).ConfigureAwait(false); + compressedFileStream.Seek(0, SeekOrigin.Begin); + } catch + { + await compressedFileStream.DisposeAsync().ConfigureAwait(false); + compressedFileInfo.Delete(); + throw; + } + entryTaskData.CompressedFileStream = compressedFileStream; + ProgressStageChangeCallback(true, downloadBytesCompleted, downloadBytesTotal); + return; + + + void ProgressStageChangeCallback(bool completed, long? progress = null, long? byteCount = null) + { + ProgressReport(ProcessingStageEnum.DownloadingFile, + completed, progress, byteCount, entry: entry); //报告实体进度 + } + } + + private async Task EntryExtract(EntryTaskData entryTaskData, CancellationToken cancellationToken = default) + { + var entry = entryTaskData.Entry; + var compressedFileInfo = entryTaskData.CompressedFileInfo; + var compressedFileStream = entryTaskData.CompressedFileStream!; + var extractedFileTempInfo = entryTaskData.ExtractedFileTempInfo; + + ProgressStageChangeCallback(false, 0, entry.Size); + using var zipFile = new ZipFile(compressedFileStream); + string? testErrorMessage = null; + var testResult = zipFile.TestArchive(testData: false, TestStrategy.FindFirstError, (_, message) => + { + if (!string.IsNullOrEmpty(message)) testErrorMessage = message; + }); + if (!testResult) + { + zipFile.Close(); + await compressedFileStream.DisposeAsync().ConfigureAwait(false); + compressedFileInfo.Delete(); + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, testErrorMessage!); + } + + var copiedBytesCount = 0L; + extractedFileTempInfo.Delete(); + var extractedFileTempWriteStream = extractedFileTempInfo.Open(new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + PreallocationSize = entry.Size, + Options = FileOptions.WriteThrough + }); + await using var _ = extractedFileTempWriteStream.ConfigureAwait(false); + try + { + var inputStream = zipFile.GetInputStream(0); + await using var ____ = inputStream.ConfigureAwait(false); + await inputStream.CopyToAsync(extractedFileTempWriteStream, new Progress(count => + { + copiedBytesCount = count; + ProgressStageChangeCallback(false, copiedBytesCount, entry.Size); + }), cancellationToken).ConfigureAwait(false); + await extractedFileTempWriteStream.FlushAsync(cancellationToken).ConfigureAwait(false); + extractedFileTempInfo.CreationTimeUtc = extractedFileTempInfo.LastWriteTimeUtc = entry.DateTime; + } + catch + { + await extractedFileTempWriteStream.DisposeAsync().ConfigureAwait(false); + extractedFileTempInfo.Delete(); + throw; + } + + ProgressStageChangeCallback(true, copiedBytesCount, entry.Size); + return; + + + void ProgressStageChangeCallback(bool completed, long? progress = null, long? byteCount = null) + { + ProgressReport(ProcessingStageEnum.ExtractingFile, + completed, progress, byteCount, entry: entry); //报告实体进度 + } + } + + private async Task FileCrcVerify(EntryTaskData entryTaskData, CancellationToken cancellationToken = default) + { + var entry = entryTaskData.Entry; + var extractedFileTempInfo = entryTaskData.ExtractedFileTempInfo; + + var extractedFileLength = extractedFileTempInfo.Length; + + ProgressStageChangeCallback(false, 0, extractedFileLength); + var bytesCount = 0L; + + var result = true; + if (entry.Crc != await GetCrcAsync(extractedFileTempInfo, new Progress(count => + { + bytesCount = count; + ProgressStageChangeCallback(false, count, extractedFileLength); + }), cancellationToken).ConfigureAwait(false)) + { + result = false; + extractedFileTempInfo.Delete(); + CrcVerificationFailedException.ThrowByZipEntryName(entry.Name); + } + + ProgressStageChangeCallback(true, bytesCount, extractedFileLength); + return result; + + + void ProgressStageChangeCallback(bool completed, long? progress = null, long? byteCount = null) + { + ProgressReport(ProcessingStageEnum.CrcVerifyingFile, + completed, progress, byteCount, entry: entry); //报告实体进度 + } + } + + /// + /// 通过ZIP实体对象创建文件夹 + /// + /// 的实例 + /// 是否对下载好的文件进行解压(只对半流式下载模式生效) + /// 进度报告回调方法 + private void CreateDirectoryByEntry(ZipEntry entry, bool extractFiles, + Action processingStageChangedCallback) + { + DirectoryInfo? fullTempDirectoryInfo = null; + DirectoryInfo fullTargetDirectoryInfo; + var entryCleanName = ZipEntry.CleanName(entry.Name); + var stageSent = false; //如果文件夹已经存在了,不再报告进度 + + if (!EnableFullStreamDownload) + { + fullTempDirectoryInfo = new DirectoryInfo(Path.Join(TempDirectoryInfo.FullName, entryCleanName)); + if (!fullTempDirectoryInfo.Exists) + { + processingStageChangedCallback(ProcessingStageEnum.CreatingDirectory, false); + stageSent = true; + fullTempDirectoryInfo.Create(); + } + + if (!extractFiles) return; + } + + //如果临时文件夹和目标文件夹相同,则不再重复创建文件夹 + if (TempDirectoryInfo.FullName != TargetDirectoryInfo.FullName || fullTempDirectoryInfo == null) + { + fullTargetDirectoryInfo = new DirectoryInfo(Path.Join(TargetDirectoryInfo.FullName, entryCleanName)); + if (!fullTargetDirectoryInfo.Exists) + { + if (!stageSent) + { + processingStageChangedCallback(ProcessingStageEnum.CreatingDirectory, false); + stageSent = true; + } + + fullTargetDirectoryInfo.Create(); + } + } + else fullTargetDirectoryInfo = fullTempDirectoryInfo; + + //设置文件夹创建时间为压缩文件时间 + fullTargetDirectoryInfo.CreationTimeUtc = fullTargetDirectoryInfo.LastWriteTimeUtc = entry.DateTime; + if (stageSent) processingStageChangedCallback(ProcessingStageEnum.CreatingDirectory, true); + } + + /// + /// 下载并打开中央目录文件。 + /// + /// 的实例 + /// 要下载的文件的文件信息 + /// 进度报告回调 + /// 取消令牌 + /// 一个任务,可以获取的实例。 + private static async Task DownloadAndOpenCentralDirectoryDataFileAsync + (ZipFileDownload zipFileDownload, FileInfo fileInfo, + Action processingStageChangedCallback, + CancellationToken cancellationToken = default) + { + ZipFile? zipFile = null; + if (fileInfo.Exists) + { + //尝试解析已存在的文件,如果是解析不了,说明为损坏文件,重新下载 + try + { + zipFile = new ZipFile(fileInfo.FullName); + } + catch (ZipException) + { + } + catch (EndOfStreamException) + { + //ICSharpCode.SharpZipLib库没有处理好,解析文件出错也有可能引发读取到流末尾的异常 + } + if (zipFile != null) return zipFile; + fileInfo.Delete(); + } + + var progress = 0L; + long? downloadByteCount = null; + processingStageChangedCallback(ProcessingStageEnum.DownloadingCentralDirectoryDataFile, + false, progress, downloadByteCount); + var fileStream = fileInfo.Open(new FileStreamOptions + { + Mode = FileMode.OpenOrCreate, + Access = FileAccess.ReadWrite, + Options = FileOptions.SequentialScan + }); + await using var _ = fileStream.ConfigureAwait(false); + try + { + fileStream.SetLength(0); + await zipFileDownload.GetCentralDirectoryDataAsync(fileStream, + new Progress(args => + { + progress = args.DownloadBytesCompleted; + downloadByteCount = args.DownloadBytesTotal; + processingStageChangedCallback(ProcessingStageEnum.DownloadingCentralDirectoryDataFile, + false, progress, downloadByteCount); + }), cancellationToken).ConfigureAwait(false); + await fileStream.FlushAsync(cancellationToken).ConfigureAwait(false); + processingStageChangedCallback(ProcessingStageEnum.DownloadingCentralDirectoryDataFile, + true, progress, downloadByteCount); + return new ZipFile(fileStream, false); + } + finally + { + await fileStream.DisposeAsync().ConfigureAwait(false); + } + } + + /// + /// 计算CRC32校验和(异步) + /// + /// 要计算CRC32校验和的文件的文件信息 + /// 进度报告对象(报告已经读取的字节数) + /// 取消令牌 + /// 一个任务,可以获取CRC32校验和。 + private static async Task GetCrcAsync(FileInfo fileInfo, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + var fileStream = fileInfo.Open(new FileStreamOptions + { + Mode = FileMode.Open, + Access = FileAccess.Read, + Options = FileOptions.SequentialScan + }); + await using var _ = fileStream.ConfigureAwait(false); + return await GetCrcAsync(fileStream, progress, cancellationToken).ConfigureAwait(false); + } + + /// + /// 计算CRC32校验和(异步) + /// + /// 要计算CRC32校验和的数据流 + /// 进度报告对象(报告已经读取的字节数) + /// 取消令牌 + /// 一个任务,可以获取CRC32校验和。 + private static async Task GetCrcAsync(Stream stream, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + var crc32ThreadLocal = new ThreadLocal(() => new Crc32()); + var crc32 = crc32ThreadLocal.Value!; + crc32.Reset(); + + var count = 0; + var buffer = ArrayPool.Shared.Rent(81920); + try + { + int bytesRead; + while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + { + crc32.Update(buffer[..bytesRead]); + count += bytesRead; + progress?.Report(count); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + return crc32.Value; + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/FastZipStreamDownloadProgress.partial.cs b/src/Starward.Core.ZipStreamDownload/FastZipStreamDownloadProgress.partial.cs new file mode 100644 index 000000000..987b24538 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/FastZipStreamDownloadProgress.partial.cs @@ -0,0 +1,138 @@ +using ICSharpCode.SharpZipLib.Zip; + +namespace Starward.Core.ZipStreamDownload; + +/// +/// ZIP流式解压类 +/// +public partial class FastZipStreamDownload +{ + /// + /// 处理阶段枚举 + /// + public enum ProcessingStageEnum + { + /// + /// 无操作 + /// + None, + + /// + /// 正在下载中央文件夹信息文件 + /// + DownloadingCentralDirectoryDataFile, + + /// + /// 正在创建文件夹 + /// + CreatingDirectory, + + /// + /// 正在验证已经存在的文件 + /// + VerifyingExistingFile, + + /// + /// 正在下载文件 + /// + DownloadingFile, + + /// + /// 正在解压文件 + /// + ExtractingFile, + + /// + /// 正在下载和解压文件 + /// + DownloadingAndExtractingFile, + + /// + /// 正在流式解压文件 + /// + StreamExtractingFile, + + /// + /// 正在进行CRC32校验 + /// + CrcVerifyingFile + } + + /// + /// 进度报告参数 + /// + /// 当前处理阶段 + /// 当前任务是否完成 + /// 当前任务已经完成的字节数 + /// 当前任务需处理的总字节数 + /// 当前任务异常 + /// 当前任务处理的 + /// 需处理的列表 + public class ProgressChangedArgs( + ProcessingStageEnum processingStage, + bool completed, + long? bytesCompleted, + long? bytesTotal, + Exception? exception, + ZipEntry? entry, + IReadOnlyCollection? entries + ) + { + /// + /// 当前处理阶段 + /// + public ProcessingStageEnum ProcessingStage { get; } = processingStage; + + /// + /// 当前任务是否完成 + /// + public bool Completed { get; } = completed; + + /// + /// 当前任务已经完成的字节数 + /// + public long? BytesCompleted { get; } = bytesCompleted; + + /// + /// 当前任务需处理的总字节数 + /// + public long? BytesTotal { get; } = bytesTotal; + + /// + /// 当前任务异常 + /// + public Exception? Exception { get; } = exception; + + /// + /// 当前任务处理的 + /// + public ZipEntry? Entry { get; } = entry; + + /// + /// 需处理的列表 + /// + public IReadOnlyCollection? Entries { get; } = entries; + } + + /// + /// 如果进度报告属性不为空,则立即报告进度。 + /// + /// 当前处理阶段 + /// 当前任务是否完成 + /// 当前任务已经完成的字节数 + /// 当前任务需处理的总字节数 + /// 当前任务异常 + /// 当前任务处理的 + /// 需处理的列表 + private void ProgressReport(ProcessingStageEnum processingStage, + bool completed, + long? bytesCompleted = null, + long? bytesTotal = null, + Exception? exception = null, + ZipEntry? entry = null, + IReadOnlyCollection? entries = null) + { + Progress?.Report(new ProgressChangedArgs(processingStage, completed, bytesCompleted, bytesTotal, exception, + entry, entries)); + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/FastZipStreamDownloadProgressUtils.cs b/src/Starward.Core.ZipStreamDownload/FastZipStreamDownloadProgressUtils.cs new file mode 100644 index 000000000..6c69cbbc5 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/FastZipStreamDownloadProgressUtils.cs @@ -0,0 +1,418 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using ICSharpCode.SharpZipLib.Zip; + +namespace Starward.Core.ZipStreamDownload; + +/// +/// 的进度报告帮助类 +/// +public class FastZipStreamDownloadProgressUtils +{ + /// + /// 实体进度状态 + /// + public class EntryStatus + { + /// + /// 当前处理阶段 + /// + public FastZipStreamDownload.ProcessingStageEnum ProcessingStage { get; internal set; } + + /// + /// 当前任务是否完成 + /// + public bool VerifyCompleted { get; internal set; } + + /// + /// 当前任务是否完成 + /// + public bool DownloadCompleted { get; internal set; } + + /// + /// 当前任务是否完成 + /// + public bool ExtractCompleted { get; internal set; } + + /// + /// 当前任务是否完成 + /// + public bool CrcVerifyCompleted { get; internal set; } + + /// + /// 当前实体已经验证的字节数 + /// + public long? VerifyBytesCompleted { get; internal set; } + + /// + /// 当前实体体需要验证的字节数 + /// + public long? VerifyBytesTotal { get; internal set; } + + /// + /// 当前实体已经下载的字节数 + /// + public long? DownloadBytesCompleted { get; internal set; } + + /// + /// 当前实体已经下载的字节数(验证成功跳过) + /// + public long? DownloadBytesCompletedIfVerified { get; internal set; } + + /// + /// 当前实体需要已经下载的字节数 + /// + public long? DownloadBytesTotal { get; internal set; } + + /// + /// 当前实体已经进行解压的字节数 + /// + public long? ExtractBytesCompleted { get; internal set; } + + /// + /// 当前实体已经进行解压的字节数(验证成功跳过) + /// + public long? ExtractBytesCompletedIfVerified { get; internal set; } + + /// + /// 当前实体需要解压的字节数 + /// + public long? ExtractBytesTotal { get; internal set; } + + /// + /// 当前实体已经进行CRC校验的字节数 + /// + public long? CrcVerifyBytesCompleted { get; internal set; } + + /// + /// 当前实体需要进行CRC校验的字节数 + /// + public long? CrcVerifyBytesTotal { get; internal set; } + + /// + /// 当前任务异常 + /// + public Exception? Exception { get; internal set; } + } + + /// + /// 获取传递给的Progress属性的实例。 + /// + public IProgress Progress { get; } + + /// + /// 进度改变事件 + /// + /// 基于性能考虑,该事件最短触发间隔为100ms。 + public event EventHandler? ProgressUpdateEvent; + + /// + /// 当前的整体状态 + /// + public FastZipStreamDownload.ProcessingStageEnum CurrentProcessingStage { get; private set; } = + FastZipStreamDownload.ProcessingStageEnum.None; + + /// + /// 各实体的当前状态 + /// + public IReadOnlyDictionary EntriesStatus => + _entriesStatus.AsReadOnly(); + + /// + /// 需要下载的文件的实体集合 + /// + public IReadOnlyCollection? FileEntries => _fileEntries; + + /// + /// 需要下载的文件夹实体集合 + /// + public IReadOnlyCollection? DirectoryEntries => _directoryEntries; + + /// + /// 需下载的字节总数 + /// + public long? DownloadBytesTotal { get; private set; } + + /// + /// 需解压的字节总数 + /// + public long? ExtractBytesTotal { get; private set; } + + /// + /// 下载完成的字节数(包含验证成功跳过的字节数) + /// + public long DownloadBytesCompleted { get; private set; } + + /// + /// 解压完成的字节数(包含验证成功跳过的字节数) + /// + public long ExtractBytesCompleted { get; private set; } + + /// + /// 下载完成百分比 + /// + public double DownloadCompletionPercentage { get; private set; } + + /// + /// 解压完成百分比 + /// + public double ExtractCompletionPercentage { get; private set; } + + /// + /// 每秒下载字节数 + /// + public double DownloadBytesPerSecond { get; private set; } + + /// + /// 每秒解压字节数 + /// + public double ExtractBytesPerSecond { get; private set; } + + /// + /// 下载预估剩余时间 + /// + public TimeSpan DownloadRemainingTime { get; private set; } + + /// + /// 解压预估剩余时间 + /// + public TimeSpan ExtractRemainingTime { get; private set; } + + /// + /// 总体任务异常 + /// + public Exception? Exception { get; private set; } + + /// + /// 各实体的当前状态 + /// + private readonly ConcurrentDictionary _entriesStatus; + + /// + /// 需要下载的文件的实体集合 + /// + private IReadOnlyCollection? _fileEntries; + + /// + /// 需要下载的文件夹实体集合 + /// + private IReadOnlyCollection? _directoryEntries; + + /// + /// 进度刷新任务 + /// + private readonly Task _progressRefreshTask; + + /// + /// 速度刷新任务 + /// + private readonly Task _speedRefreshTask; + + /// + /// 进度刷新取消令牌源 + /// + private readonly CancellationTokenSource _progressRefreshCancellationTokenSource; + + + /// + /// 是否需要刷新进度(0:不需要,1:需要) + /// + private int _needRefreshProgress; + /// + /// 是否需要刷新速度(0:不需要,1:需要) + /// + private int _needRefreshSpeed; + + /// + /// 初始化一个的进度报告帮助类的实例 + /// + public FastZipStreamDownloadProgressUtils() + { + _entriesStatus = new ConcurrentDictionary(); + + _progressRefreshCancellationTokenSource = new CancellationTokenSource(); + var progressRefreshCancellationToken = _progressRefreshCancellationTokenSource.Token; + _progressRefreshTask = Task.Run(() => + ProgressRefresh(progressRefreshCancellationToken), progressRefreshCancellationToken); + _speedRefreshTask = Task.Run(() => + SpeedRefresh(progressRefreshCancellationToken), progressRefreshCancellationToken); + + Progress = new Progress(ProgressUpdate); + } + + ~FastZipStreamDownloadProgressUtils() + { + _progressRefreshCancellationTokenSource.Cancel(); + _progressRefreshTask.Wait(); + _speedRefreshTask.Wait(); + } + + /// + /// 进度更新方法 + /// + /// 进度报告参数 + private void ProgressUpdate(FastZipStreamDownload.ProgressChangedArgs args) + { + if (args.Entry == null) + { + CurrentProcessingStage = args.ProcessingStage; + Exception = args.Exception; + switch (args.ProcessingStage) + { + case FastZipStreamDownload.ProcessingStageEnum.CreatingDirectory: + { + if (args.Entries != null) _directoryEntries = args.Entries; + break; + } + case FastZipStreamDownload.ProcessingStageEnum.DownloadingAndExtractingFile: + { + if (args.Entries != null) _fileEntries = args.Entries; + break; + } + } + } + else SetEntryStatus(args); + + _needRefreshProgress = 1; + _needRefreshSpeed = 1; + } + + /// + /// 进度刷新方法 + /// + /// 取消令牌 + /// 一个任务。 + private async Task ProgressRefresh(CancellationToken cancellationToken) + { + while (true) + { + try + { + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + if (Interlocked.CompareExchange(ref _needRefreshProgress, 0, 1) != 1) continue; + + DownloadBytesTotal ??= _fileEntries?.Sum(e => e.CompressedSize); + DownloadBytesCompleted = _entriesStatus.Values.Sum(e => e.DownloadBytesCompletedIfVerified ?? 0); + DownloadCompletionPercentage = DownloadBytesTotal is null or 0 + ? 0 + : (double)DownloadBytesCompleted * 100 / DownloadBytesTotal.Value; + if (DownloadBytesTotal.HasValue && DownloadBytesPerSecond > 0) + DownloadRemainingTime = + TimeSpan.FromSeconds((DownloadBytesTotal.Value - DownloadBytesCompleted) / DownloadBytesPerSecond); + + ExtractBytesTotal ??= _fileEntries?.Sum(e => e.Size); + ExtractBytesCompleted = _entriesStatus.Values.Sum(e => e.ExtractBytesCompletedIfVerified ?? 0); + ExtractCompletionPercentage = ExtractBytesTotal is null or 0 + ? 0 + : (double)ExtractBytesCompleted * 100 / ExtractBytesTotal.Value; + if (ExtractBytesTotal.HasValue && ExtractBytesPerSecond > 0) + ExtractRemainingTime = + TimeSpan.FromSeconds((ExtractBytesTotal.Value - ExtractBytesCompleted) / ExtractBytesPerSecond); + + ProgressUpdateEvent?.Invoke(this, EventArgs.Empty); + } + } + + /// + /// 速度刷新方法 + /// + /// 取消令牌 + /// 一个任务。 + private async Task SpeedRefresh(CancellationToken cancellationToken) + { + var downloadBytesCompleted = 0L; + var extractBytesCompleted = 0L; + + var stopwatch = new Stopwatch(); + while (true) + { + try + { + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + var lastDownloadBytesCompleted = downloadBytesCompleted; + var lastExtractBytesCompleted = extractBytesCompleted; + if (Interlocked.CompareExchange(ref _needRefreshSpeed, 0, 1) == 1) + { + downloadBytesCompleted = _entriesStatus.Values.Sum(e => e.DownloadBytesCompleted ?? 0); + extractBytesCompleted = _entriesStatus.Values.Sum(e => e.ExtractBytesCompleted ?? 0); + DownloadBytesPerSecond = + (downloadBytesCompleted - lastDownloadBytesCompleted) * 1000 / (double)stopwatch.ElapsedMilliseconds; + ExtractBytesPerSecond = + (extractBytesCompleted - lastExtractBytesCompleted) * 1000 / (double)stopwatch.ElapsedMilliseconds; + } + else + { + DownloadBytesPerSecond = ExtractBytesPerSecond = 0; + } + + stopwatch.Restart(); + } + stopwatch.Stop(); + } + + /// + /// 根据进度报告参数设置实体状态 + /// + /// 进度报告参数 + private void SetEntryStatus(FastZipStreamDownload.ProgressChangedArgs args) + { + if (args.Entry == null) return; + var entryStatus = _entriesStatus.GetOrAdd(args.Entry, _ => new EntryStatus()); + + entryStatus.ProcessingStage = args.ProcessingStage; + entryStatus.Exception = args.Exception; + + switch (args.ProcessingStage) + { + case FastZipStreamDownload.ProcessingStageEnum.VerifyingExistingFile: + { + if (args.BytesCompleted != null) entryStatus.VerifyBytesCompleted = args.BytesCompleted; + if (args.BytesTotal != null) entryStatus.VerifyBytesTotal = args.BytesTotal; + if (args.Completed) entryStatus.VerifyCompleted = true; + break; + } + case FastZipStreamDownload.ProcessingStageEnum.DownloadingFile or + FastZipStreamDownload.ProcessingStageEnum.StreamExtractingFile: + { + if (args.BytesCompleted != null) + entryStatus.DownloadBytesCompletedIfVerified = entryStatus.DownloadBytesCompleted = args.BytesCompleted; + if (args.BytesTotal != null) entryStatus.DownloadBytesTotal = args.BytesTotal; + if (args.Completed) entryStatus.DownloadCompleted = true; + break; + } + case FastZipStreamDownload.ProcessingStageEnum.ExtractingFile: + { + if (args.BytesCompleted != null) + entryStatus.ExtractBytesCompletedIfVerified = entryStatus.ExtractBytesCompleted = args.BytesCompleted; + if (args.BytesTotal != null) entryStatus.ExtractBytesTotal = args.BytesTotal; + if (args.Completed) entryStatus.ExtractCompleted = true; + break; + } + case FastZipStreamDownload.ProcessingStageEnum.CrcVerifyingFile: + { + if (args.BytesCompleted != null) entryStatus.CrcVerifyBytesCompleted = args.BytesCompleted; + if (args.BytesTotal != null) entryStatus.CrcVerifyBytesTotal = args.BytesTotal; + if (args.Completed) entryStatus.CrcVerifyCompleted = true; + break; + } + case FastZipStreamDownload.ProcessingStageEnum.None: + { + if (!entryStatus.DownloadCompleted) + entryStatus.DownloadBytesCompletedIfVerified = args.Entry.CompressedSize; + if (!entryStatus.ExtractCompleted) + entryStatus.ExtractBytesCompletedIfVerified = args.Entry.Size; + break; + } + } + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/FastZipStreamDownloadTask.partial.cs b/src/Starward.Core.ZipStreamDownload/FastZipStreamDownloadTask.partial.cs new file mode 100644 index 000000000..eac05bf3b --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/FastZipStreamDownloadTask.partial.cs @@ -0,0 +1,270 @@ +using System.Collections.Concurrent; +using ICSharpCode.SharpZipLib.Zip; +using Starward.Core.ZipStreamDownload.Exceptions; +using Starward.Core.ZipStreamDownload.Extensions; +using Starward.Core.ZipStreamDownload.Http.Exceptions; + +namespace Starward.Core.ZipStreamDownload; + +/// +/// ZIP流式解压类 +/// +public partial class FastZipStreamDownload +{ + private class EntryTaskData + { + public required ZipEntry Entry { get; init; } + + public required DirectoryInfo ExtractedFileDirectoryInfo { get; init; } + + public required FileInfo ExtractedFileInfo { get; init; } + + public required FileInfo ExtractedFileTempInfo { get; init; } + + public required DirectoryInfo CompressedFileDirectoryInfo { get; init; } + + public required FileInfo CompressedFileInfo { get; init; } + + public Stream? CompressedFileStream { get; set; } + } + + private readonly ConcurrentQueue _fileVerifyTaskQueue = new(); + + private int _fileVerifyTaskCount; + + private readonly ConcurrentQueue _entryDownloadTaskQueue = new(); + + private int _entryDownloadTaskCount; + + private readonly ConcurrentQueue _entryExtractAndFileCrcVerifyTaskQueue = new(); + + private int _entryExtractAndFileCrcVerifyTaskCount; + + private int _entryTaskCount; + + private void CleanEntryTasks() + { + _fileVerifyTaskQueue.Clear(); + _fileVerifyTaskCount = 0; + _entryDownloadTaskQueue.Clear(); + _entryDownloadTaskCount = 0; + _entryExtractAndFileCrcVerifyTaskQueue.Clear(); + _entryExtractAndFileCrcVerifyTaskCount = 0; + _entryTaskCount = 0; + } + + private void AddEntryTask(ZipEntry entry) + { + var fileDirectory = entry.GetFileDirectory() ?? ""; + var fileName = entry.GetFileName(); + if (fileName == null) InvalidZipEntryNameException.ThrowByZipEntryName(entry.Name); + + var tempFileDirectory = Path.Join(TempDirectoryInfo.FullName, fileDirectory); + var tempFileInfo = new FileInfo(Path.Join(tempFileDirectory, fileName)); + _ = Directory.CreateDirectory(tempFileDirectory); + + FileInfo targetFileInfo; + if (TempDirectoryInfo.FullName != TargetDirectoryInfo.FullName) + { + var targetFileDirectory = Path.Join(TargetDirectoryInfo.FullName, fileDirectory); + targetFileInfo = new FileInfo(Path.Join(targetFileDirectory, fileName)); + _ = Directory.CreateDirectory(targetFileDirectory); + } + else targetFileInfo = tempFileInfo; + + var compressedFileInfo = new FileInfo($"{tempFileInfo.FullName}.zip"); + + var entryData = new EntryTaskData + { + Entry = entry, + CompressedFileDirectoryInfo = TempDirectoryInfo, + CompressedFileInfo = compressedFileInfo, + ExtractedFileDirectoryInfo = TargetDirectoryInfo, + ExtractedFileInfo = targetFileInfo, + ExtractedFileTempInfo = new FileInfo($"{targetFileInfo.FullName}_tmp") + }; + + _fileVerifyTaskQueue.Enqueue(entryData); + _entryTaskCount += 1; + } + + private async Task WaitExecuteEntryTasksAsync(IZipFileDownloadFactory zipFileDownloadFactory, + bool extractFiles, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var existingFileVerifyThreadCount = Math.Min(_entryTaskCount, _existingFileVerifyThreadCount); + var downloadThreadCount = Math.Min(_entryTaskCount, _downloadThreadCount); + var extractAndCrcVerifyThreadCount = Math.Min(_entryTaskCount, _extractAndCrcVerifyThreadCount); + try + { + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var taskList = new List( + existingFileVerifyThreadCount + downloadThreadCount + extractAndCrcVerifyThreadCount); + for (var i = 0; i < existingFileVerifyThreadCount; i++) + taskList.Add(FileVerifyTaskMethod(cancellationTokenSource.Token)); + for (var i = 0; i < downloadThreadCount; i++) + taskList.Add(EntryDownloadTaskMethod(zipFileDownloadFactory, EnableFullStreamDownload, + cancellationTokenSource.Token)); + for (var i = 0; i < extractAndCrcVerifyThreadCount; i++) + taskList.Add(EntryExtractAndFileCrcVerifyTaskMethod(EnableFullStreamDownload, extractFiles, + cancellationTokenSource.Token)); + taskList.ForEach(t => t.ConfigureAwait(false).GetAwaiter().OnCompleted(() => + { + // ReSharper disable once AccessToDisposedClosure + if (t.IsFaulted) cancellationTokenSource.CancelAsync(); + })); + var whenAllTask = Task.WhenAll(taskList); + try + { + await whenAllTask.ConfigureAwait(false); + } + catch + { + // ignored + } + if (whenAllTask.Exception is { InnerExceptions.Count: > 0 }) + { + var innerExceptions = whenAllTask.Exception.InnerExceptions; + if (innerExceptions.Count == 1) throw innerExceptions[0]; + if (innerExceptions.Count > 1) throw new AggregateException(innerExceptions); + } + cancellationToken.ThrowIfCancellationRequested(); + } + finally + { + CleanEntryTasks(); + } + } + + private async Task FileVerifyTaskMethod( + CancellationToken cancellationToken = default) + { + while (!cancellationToken.IsCancellationRequested) + { + if (!_fileVerifyTaskQueue.TryDequeue(out var taskData)) + { + if (_fileVerifyTaskCount >= _entryTaskCount) break; + await Task.Delay(10, cancellationToken).ConfigureAwait(false); + continue; + } + + var skipFollowupTasks = true; + try + { + if (await FileVerify(taskData, cancellationToken).ConfigureAwait(false)) + { + _entryDownloadTaskQueue.Enqueue(taskData); + skipFollowupTasks = false; + } + else ProgressReport(ProcessingStageEnum.None, true, entry: taskData.Entry); + } + catch (Exception e) + { + ProgressReport(ProcessingStageEnum.None, true, exception: e, entry: taskData.Entry); + _entriesExceptionDictionary[taskData.Entry] = e; + + if (e is OperationCanceledException or TaskCanceledException) return; + } + finally + { + if (skipFollowupTasks) + { + Interlocked.Increment(ref _entryDownloadTaskCount); + Interlocked.Increment(ref _entryExtractAndFileCrcVerifyTaskCount); + } + Interlocked.Increment(ref _fileVerifyTaskCount); + } + } + } + + private async Task EntryDownloadTaskMethod(IZipFileDownloadFactory zipFileDownloadFactory, + bool enableFullStreamDownload, CancellationToken cancellationToken = default) + { + while (!cancellationToken.IsCancellationRequested) + { + if (!_entryDownloadTaskQueue.TryDequeue(out var taskData)) + { + if (_entryDownloadTaskCount >= _entryTaskCount) break; + await Task.Delay(10, cancellationToken).ConfigureAwait(false); + continue; + } + + var skipFollowupTasks = true; + try + { + var zipFileDownloadThreadLocal = + new ThreadLocal(zipFileDownloadFactory.GetInstance); + if (enableFullStreamDownload) + await EntryDownloadStream(zipFileDownloadThreadLocal.Value!, taskData, cancellationToken) + .ConfigureAwait(false); + else + await EntryDownloadCompressedFile(zipFileDownloadThreadLocal.Value!, taskData, cancellationToken) + .ConfigureAwait(false); + _entryExtractAndFileCrcVerifyTaskQueue.Enqueue(taskData); + skipFollowupTasks = false; + } + catch (HttpFileModifiedDuringPartialDownload) + { + //文件在下载过程中被修改了 + if (taskData.CompressedFileStream != null) + await taskData.CompressedFileStream.DisposeAsync().ConfigureAwait(false); + throw; + } + catch (Exception e) + { + ProgressReport(ProcessingStageEnum.None, true, exception: e, entry: taskData.Entry); + _entriesExceptionDictionary[taskData.Entry] = e; + + if (taskData.CompressedFileStream != null) + await taskData.CompressedFileStream.DisposeAsync().ConfigureAwait(false); + + if (e is OperationCanceledException or TaskCanceledException) return; + } + finally + { + if (skipFollowupTasks) + Interlocked.Increment(ref _entryExtractAndFileCrcVerifyTaskCount); + Interlocked.Increment(ref _entryDownloadTaskCount); + } + } + } + + private async Task EntryExtractAndFileCrcVerifyTaskMethod(bool enableFullStreamDownload, bool extractFiles, + CancellationToken cancellationToken = default) + { + while (!cancellationToken.IsCancellationRequested) + { + if (!_entryExtractAndFileCrcVerifyTaskQueue.TryDequeue(out var taskData)) + { + if (_entryExtractAndFileCrcVerifyTaskCount >= _entryTaskCount) break; + await Task.Delay(10, cancellationToken).ConfigureAwait(false); + continue; + } + + try + { + if (!enableFullStreamDownload && extractFiles) + await EntryExtract(taskData, cancellationToken).ConfigureAwait(false); + var crcCheckResult = true; + if (CheckCrcExtracted) crcCheckResult = + await FileCrcVerify(taskData, cancellationToken).ConfigureAwait(false); + if (crcCheckResult) + taskData.ExtractedFileTempInfo.MoveTo(taskData.ExtractedFileInfo.FullName, true); + } + catch (Exception e) + { + ProgressReport(ProcessingStageEnum.None, true, exception: e, entry: taskData.Entry); + _entriesExceptionDictionary[taskData.Entry] = e; + + if (e is OperationCanceledException or TaskCanceledException) return; + } + finally + { + Interlocked.Increment(ref _entryExtractAndFileCrcVerifyTaskCount); + + if (taskData.CompressedFileStream != null) + await taskData.CompressedFileStream.DisposeAsync().ConfigureAwait(false); + } + } + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Http/AutoRetryOptions.cs b/src/Starward.Core.ZipStreamDownload/Http/AutoRetryOptions.cs new file mode 100644 index 000000000..6c5e34d2d --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Http/AutoRetryOptions.cs @@ -0,0 +1,47 @@ +namespace Starward.Core.ZipStreamDownload.Http; + +/// +/// 自动重试选项 +/// +public class AutoRetryOptions +{ + /// + /// 当网络错误时可允许的最大重试次数 + /// 取值范围(0,20),默认10 + /// + public int RetryTimesOnNetworkError + { + get => _retryTimesOnNetworkError; + set + { + ArgumentOutOfRangeException.ThrowIfLessThan(value, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 20); + _retryTimesOnNetworkError = value; + } + } + + /// + /// (内部)当网络错误时可允许的最大重试次数 + /// + private int _retryTimesOnNetworkError = 10; + + /// + /// 自动重试等待时间(单位:毫秒) + /// 取值范围(0,2000),默认1000 + /// + public int DelayMillisecond + { + get => _delayMillisecond; + set + { + ArgumentOutOfRangeException.ThrowIfLessThan(value, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 20); + _delayMillisecond = value; + } + } + + /// + /// (内部)自动重试等待时间(单位:毫秒) + /// + private int _delayMillisecond = 1000; +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpBodyLengthNotMatchException.cs b/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpBodyLengthNotMatchException.cs new file mode 100644 index 000000000..de4540172 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpBodyLengthNotMatchException.cs @@ -0,0 +1,38 @@ +using System.Diagnostics.CodeAnalysis; +using Starward.Core.ZipStreamDownload.Resources; + +namespace Starward.Core.ZipStreamDownload.Http.Exceptions; + +/// +/// 当HTTP请求体的长度与请求头相关参数不匹配引发的异常。 +/// +public class HttpBodyLengthNotMatchException : HttpPartialDownloadException +{ + /// + /// 创建一个当HTTP请求体的长度与请求头相关参数不匹配引发的异常的实例。 + /// + /// 异常消息 + public HttpBodyLengthNotMatchException(string? message) : base(message) + { + } + + /// + /// 创建一个当HTTP请求体的长度与请求头相关参数不匹配引发的异常的实例。 + /// + /// 异常消息 + /// 引发该异常的异常 + public HttpBodyLengthNotMatchException(string? message, Exception? innerException) : base(message, innerException) + { + } + + /// + /// 引发TTP请求体的长度与请求头相关参数不匹配的异常。 + /// + /// 当HTTP请求体的长度与请求头相关参数不匹配时引发此异常。 + [DoesNotReturn] + internal static void Throw() + { + throw new HttpBodyLengthNotMatchException( + string.Format(ExceptionMessages.HttpFileModifiedDuringPartialDownloadMessage)); + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpFileModifiedDuringPartialDownload.cs b/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpFileModifiedDuringPartialDownload.cs new file mode 100644 index 000000000..9d9ab7544 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpFileModifiedDuringPartialDownload.cs @@ -0,0 +1,38 @@ +using System.Diagnostics.CodeAnalysis; +using Starward.Core.ZipStreamDownload.Resources; + +namespace Starward.Core.ZipStreamDownload.Http.Exceptions; + +/// +/// 当HTTP服务器上的文件在分段下载过程中被修改引发的异常。 +/// +public class HttpFileModifiedDuringPartialDownload : HttpPartialDownloadException +{ + /// + /// 创建一个当HTTP服务器上的文件在分段下载过程中被修改引发的异常的实例。 + /// + /// 异常消息 + public HttpFileModifiedDuringPartialDownload(string? message) : base(message) + { + } + + /// + /// 创建一个当HTTP服务器上的文件在分段下载过程中被修改引发的异常的实例。 + /// + /// 异常消息 + /// 引发该异常的异常 + public HttpFileModifiedDuringPartialDownload(string? message, Exception? innerException) : base(message, innerException) + { + } + + /// + /// 引发HTTP服务器上的文件在分段下载过程中被修改的异常。 + /// + /// 当HTTP服务器上的文件在分段下载过程中被修改时引发此异常。 + [DoesNotReturn] + internal static void Throw() + { + throw new HttpFileModifiedDuringPartialDownload( + string.Format(ExceptionMessages.HttpFileModifiedDuringPartialDownloadMessage)); + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpMediaTypeMismatchException.cs b/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpMediaTypeMismatchException.cs new file mode 100644 index 000000000..1da86c146 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpMediaTypeMismatchException.cs @@ -0,0 +1,53 @@ +using System.Net.Http.Headers; +using Starward.Core.ZipStreamDownload.Resources; + +namespace Starward.Core.ZipStreamDownload.Http.Exceptions; + +/// +/// 当HTTP服务器返回的文件类型和所需类型不匹配时引发的异常。 +/// +public class HttpMediaTypeMismatchException : HttpPartialDownloadException +{ + /// + /// 创建一个当当HTTP服务器返回的文件类型和所需类型不匹配时引发的异常的实例。 + /// + /// 异常消息 + public HttpMediaTypeMismatchException(string? message) : base(message) + { + } + + /// + /// 创建一个当HTTP服务器返回的文件类型和所需类型不匹配时引发的异常的实例。 + /// + /// 异常消息 + /// 引发该异常的异常 + public HttpMediaTypeMismatchException(string? message, Exception? innerException) : base(message, innerException) + { + } + + /// + /// 当中的MediaType与传入的字符串不匹配时引发异常。 + /// + /// 的实例 + /// 要进行验证的媒体类型 + /// 当HTTP服务器返回的文件类型和所需类型不匹配时引发此异常。 + internal static void ThrowIfMediaTypeMismatch(HttpContentHeaders contentHeaders, string? mediaType) + { + if (!string.IsNullOrEmpty(mediaType) && + contentHeaders.ContentType != null && + contentHeaders.ContentType.MediaType != "application/octet-stream" && + contentHeaders.ContentType.MediaType != mediaType) + throw new HttpMediaTypeMismatchException(ExceptionMessages.HttpMediaTypeMismatchExceptionMessage); + } + + /// + /// 当的版本小于1.1时引发的异常。 + /// + /// 的实例,表示HTTP协议版本。 + /// 当HTTP服务器返回的版本小于所需版本时引发此异常。 + internal static void ThrowIfVersionLessThenHttp11(Version version) + { + if (version.Major < 1 || version is { Major: 1, Minor: < 1 }) + throw new HttpMediaTypeMismatchException(ExceptionMessages.HttpMediaTypeMismatchExceptionMessage); + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpPartialDownloadException.cs b/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpPartialDownloadException.cs new file mode 100644 index 000000000..a49f28baf --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpPartialDownloadException.cs @@ -0,0 +1,24 @@ +namespace Starward.Core.ZipStreamDownload.Http.Exceptions; + +/// +/// 当进行HTTP分段下载时出错引发的异常。 +/// +public class HttpPartialDownloadException : Exception +{ + /// + /// 创建一个当进行HTTP分段下载时出错引发的异常的实例。 + /// + /// 异常消息 + public HttpPartialDownloadException(string? message) : base(message) + { + } + + /// + /// 创建一个当进行HTTP分段下载时出错引发的异常的实例。 + /// + /// 异常消息 + /// 引发该异常的异常 + public HttpPartialDownloadException(string? message, Exception? innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpServerNotSupportedPartialDownloadException.cs b/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpServerNotSupportedPartialDownloadException.cs new file mode 100644 index 000000000..59f795ab2 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpServerNotSupportedPartialDownloadException.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; +using Starward.Core.ZipStreamDownload.Resources; + +namespace Starward.Core.ZipStreamDownload.Http.Exceptions; + +/// +/// 当HTTP服务器可能不支持分段下载时引发的异常。 +/// +public class HttpServerNotSupportedPartialDownloadException : HttpPartialDownloadException +{ + /// + /// 创建一个当HTTP服务器可能不支持分段下载时引发的异常的实例。 + /// + /// 异常消息 + public HttpServerNotSupportedPartialDownloadException(string? message) : base(message) + { + } + + /// + /// 创建一个当HTTP服务器可能不支持分段下载时引发的异常的实例。 + /// + /// 异常消息 + /// 引发该异常的异常 + public HttpServerNotSupportedPartialDownloadException(string? message, Exception? innerException) : + base(message, innerException) + { + } + + /// + /// 引发一个HTTP服务器可能不支持分段下载的异常。 + /// + /// HTTP服务器可能不支持分段下载时引发此异常。 + [DoesNotReturn] + internal static void Throw() + { + throw new HttpServerNotSupportedPartialDownloadException( + ExceptionMessages.HttpServerNotSupportedPartialDownloadExceptionMessage); + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpStatusCodeInvalidException.cs b/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpStatusCodeInvalidException.cs new file mode 100644 index 000000000..621c21f53 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Http/Exceptions/HttpStatusCodeInvalidException.cs @@ -0,0 +1,41 @@ +using System.Net; +using Starward.Core.ZipStreamDownload.Resources; + +namespace Starward.Core.ZipStreamDownload.Http.Exceptions; + +/// +/// 当HTTP服务器返回的状态码不受支持时引发的异常。 +/// +public class HttpStatusCodeInvalidException : HttpPartialDownloadException +{ + /// + /// 创建一个当HTTP服务器返回的状态码不受支持时引发的异常的实例。 + /// + /// 异常消息 + public HttpStatusCodeInvalidException(string? message) : base(message) + { + } + + /// + /// 创建一个当HTTP服务器返回的状态码不受支持时引发的异常的实例。 + /// + /// 异常消息 + /// 引发该异常的异常 + public HttpStatusCodeInvalidException(string? message, Exception? innerException) : base(message, innerException) + { + } + + /// + /// 当参数传入的状态码不为分段下载所需的状态码时引发此异常。 + /// + /// HTTP状态码的枚举 + /// 状态消息 + /// 当HTTP服务器返回的状态码不受支持时引发此异常。 + internal static void ThrowIfCodeNotEqualPartialContent(HttpStatusCode statusCode, string? message) + { + if (statusCode == HttpStatusCode.PartialContent) return; + throw new HttpStatusCodeInvalidException( + string.Format(ExceptionMessages.HttpStatusCodeInvalidExceptionMessage, (int)statusCode, + message ?? "null")); + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Http/Extensions/HttpClientGetPartialExtensions.cs b/src/Starward.Core.ZipStreamDownload/Http/Extensions/HttpClientGetPartialExtensions.cs new file mode 100644 index 000000000..994f061da --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Http/Extensions/HttpClientGetPartialExtensions.cs @@ -0,0 +1,436 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; + +namespace Starward.Core.ZipStreamDownload.Http.Extensions; + +/// +/// 的分段下载扩展。 +/// +internal static class HttpClientGetPartialExtensions +{ + /// + /// 创建一个HTTP分段下载请求消息。 + /// + /// 的实例。 + /// 要发送请求的Uri。 + /// 的实例,代表分段下载请求头的值。 + /// 的实例,表示一个HTTP分段下载请求消息。 + /// 请求为空。 + /// + /// HTTP版本为2.0或更高版本,或者版本策略设置为。 + /// -或者- + /// 从派生的自定义类不会重写 + /// 方法。 + /// -或者- + /// 自定义不会覆盖 + /// 方法。 + /// + /// 请求消息已由实例发送。 + /// 由于网络连接、DNS故障或服务器证书验证等潜在问题,请求失败。 + /// + /// 如果异常嵌套了:请求因超时而失败。 + /// + private static HttpRequestMessage CreatePartialHttpRequestMessage + (HttpClient httpClient, Uri requestUri, RangeHeaderValue rangeHeaderValue) + { + var request = new HttpRequestMessage(HttpMethod.Get, requestUri) + { + Version = httpClient.DefaultRequestVersion, + VersionPolicy = httpClient.DefaultVersionPolicy + }; + request.Headers.Connection.Add("Keep-Alive"); + request.Headers.Range = rangeHeaderValue; + return request; + } + + /// + /// 发起一个HTTP分段下载请求。 + /// + /// 的实例。 + /// 要发送请求的Uri。 + /// + /// 分段下载的开始字节索引(包含),如果此值为空,则形参代表获取最后多少个字节的数据。 + /// + /// + /// 分段下载结束字节索引(包含),如果此值为空,则获取从形参所代表的字节索引到最后一个字节的数据。 + /// + /// 接受的文件MIME类型,如果设置了此值,将在请求时加入请求头。 + /// 的实例,表示HTTP分段下载的响应消息。 + /// 请求为空。 + /// + /// HTTP版本为2.0或更高版本,或者版本策略设置为。 + /// -或者- + /// 从派生的自定义类不会重写 + /// 方法。 + /// -或者- + /// 自定义不会覆盖 + /// 方法。 + /// + /// 请求消息已由实例发送。 + /// 由于网络连接、DNS故障或服务器证书验证等潜在问题,请求失败。 + /// + /// 如果异常嵌套了:请求因超时而失败。 + /// + public static HttpResponseMessage GetPartial(this HttpClient httpClient, + Uri requestUri, long? from, long? to, string? acceptType) + { + return GetPartial(httpClient, requestUri, null, from, to, acceptType); + } + + /// + /// 发起一个HTTP分段下载请求。 + /// + /// 的实例。 + /// 要发送请求的Uri。 + /// 如果传入,覆盖Uri中的主机名。 + /// + /// 分段下载的开始字节索引(包含),如果此值为空,则形参代表获取最后多少个字节的数据。 + /// + /// + /// 分段下载结束字节索引(包含),如果此值为空,则获取从形参所代表的字节索引到最后一个字节的数据。 + /// + /// 接受的文件MIME类型,如果设置了此值,将在请求时加入请求头。 + /// 的实例,表示HTTP分段下载的响应消息。 + /// 请求为空。 + /// + /// HTTP版本为2.0或更高版本,或者版本策略设置为。 + /// -或者- + /// 从派生的自定义类不会重写 + /// 方法。 + /// -或者- + /// 自定义不会覆盖 + /// 方法。 + /// + /// 请求消息已由实例发送。 + /// 由于网络连接、DNS故障或服务器证书验证等潜在问题,请求失败。 + /// + /// 如果异常嵌套了:请求因超时而失败。 + /// + public static HttpResponseMessage GetPartial(this HttpClient httpClient, + Uri requestUri, string? hostName, long? from, long? to, string? acceptType) + { + return GetPartial(httpClient, requestUri, hostName, new RangeHeaderValue(from, to), acceptType); + } + + /// + /// 发起一个HTTP分段下载请求(异步)。 + /// + /// 的实例。 + /// 要发送请求的Uri。 + /// + /// 分段下载的开始字节索引(包含),如果此值为空,则形参代表获取最后多少个字节的数据。 + /// + /// + /// 分段下载结束字节索引(包含),如果此值为空,则获取从形参所代表的字节索引到最后一个字节的数据。 + /// + /// 接受的文件MIME类型,如果设置了此值,将在请求时加入请求头。 + /// 取消操作的令牌。 + /// 返回一个任务,可获取一个的实例,表示HTTP分段下载的响应消息。 + /// 请求为空。 + /// + /// HTTP版本为2.0或更高版本,或者版本策略设置为。 + /// -或者- + /// 从派生的自定义类不会重写 + /// 方法。 + /// -或者- + /// 自定义不会覆盖 + /// 方法。 + /// + /// 请求消息已由实例发送。 + /// 由于网络连接、DNS故障或服务器证书验证等潜在问题,请求失败。 + /// + /// 如果异常嵌套了:请求因超时而失败。 + /// + public static Task GetPartialAsync(this HttpClient httpClient, + Uri requestUri, long? from, long? to, string? acceptType, + CancellationToken cancellationToken = default) + { + return GetPartialAsync(httpClient, requestUri, null, from, to, acceptType, cancellationToken); + } + + /// + /// 发起一个HTTP分段下载请求(异步)。 + /// + /// 的实例。 + /// 要发送请求的Uri。 + /// 如果传入,覆盖Uri中的主机名。 + /// + /// 分段下载的开始字节索引(包含),如果此值为空,则形参代表获取最后多少个字节的数据。 + /// + /// + /// 分段下载结束字节索引(包含),如果此值为空,则获取从形参所代表的字节索引到最后一个字节的数据。 + /// + /// 接受的文件MIME类型,如果设置了此值,将在请求时加入请求头。 + /// 取消操作的令牌。 + /// 返回一个任务,可获取一个的实例,表示HTTP分段下载的响应消息。 + /// 请求为空。 + /// + /// HTTP版本为2.0或更高版本,或者版本策略设置为。 + /// -或者- + /// 从派生的自定义类不会重写 + /// 方法。 + /// -或者- + /// 自定义不会覆盖 + /// 方法。 + /// + /// 请求消息已由实例发送。 + /// 由于网络连接、DNS故障或服务器证书验证等潜在问题,请求失败。 + /// + /// 如果异常嵌套了:请求因超时而失败。 + /// + public static Task GetPartialAsync(this HttpClient httpClient, + Uri requestUri, string? hostName, long? from, long? to, string? acceptType, + CancellationToken cancellationToken = default) + { + return GetPartialAsync(httpClient, requestUri, hostName, new RangeHeaderValue(from, to), acceptType, + cancellationToken); + } + + /// + /// 发起一个HTTP分段下载请求。 + /// + /// 的实例。 + /// 要发送请求的Uri。 + /// 如果传入,覆盖Uri中的主机名。 + /// 的实例,代表分段下载请求头的值。 + /// 接受的文件MIME类型,如果设置了此值,将在请求时加入请求头。 + /// 的实例,表示HTTP分段下载的响应消息。 + /// 请求为空。 + /// + /// HTTP版本为2.0或更高版本,或者版本策略设置为。 + /// -或者- + /// 从派生的自定义类不会重写 + /// 方法。 + /// -或者- + /// 自定义不会覆盖 + /// 方法。 + /// + /// 请求消息已由实例发送。 + /// 由于网络连接、DNS故障或服务器证书验证等潜在问题,请求失败。 + /// + /// 如果异常嵌套了:请求因超时而失败。 + /// + public static HttpResponseMessage GetPartial(this HttpClient httpClient, + Uri requestUri, string? hostName, RangeHeaderValue rangeHeaderValue, string? acceptType) + { + var request = CreatePartialHttpRequestMessage(httpClient, requestUri, rangeHeaderValue); + + if (!string.IsNullOrEmpty(acceptType)) + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(acceptType)); + if (!string.IsNullOrEmpty(hostName)) request.Headers.Host = hostName; + + return httpClient.Send(request, HttpCompletionOption.ResponseHeadersRead); + } + + /// + /// 发起一个HTTP分段下载请求(异步)。 + /// + /// 的实例。 + /// 要发送请求的Uri。 + /// 如果传入,覆盖Uri中的主机名。 + /// 的实例,代表分段下载请求头的值。 + /// 接受的文件MIME类型,如果设置了此值,将在请求时加入请求头。 + /// 取消操作的令牌。 + /// 返回一个任务,可获取一个的实例,表示HTTP分段下载的响应消息。 + /// 请求为空。 + /// 请求消息已由实例发送。 + /// 由于网络连接、DNS故障或服务器证书验证等潜在问题,请求失败。 + /// + /// 如果异常嵌套了:请求因超时而失败。 + /// + public static Task GetPartialAsync(this HttpClient httpClient, + Uri requestUri, string? hostName, RangeHeaderValue rangeHeaderValue, string? acceptType, + CancellationToken cancellationToken = default) + { + var request = CreatePartialHttpRequestMessage(httpClient, requestUri, rangeHeaderValue); + + if (!string.IsNullOrEmpty(acceptType)) + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(acceptType)); + if (!string.IsNullOrEmpty(hostName)) request.Headers.Host = hostName; + + return httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + } + + /// + /// 发起一个HTTP分段下载请求。 + /// + /// 的实例。 + /// 要发送请求的Uri。 + /// + /// 分段下载的开始字节索引(包含),如果此值为空,则形参代表获取最后多少个字节的数据。 + /// + /// + /// 分段下载结束字节索引(包含),如果此值为空,则获取从形参所代表的字节索引到最后一个字节的数据。 + /// + /// 接受的文件MIME类型,如果设置了此值,将在请求时加入请求头。 + /// 的实例,表示HTTP分段下载的响应消息。 + /// 请求为空。 + /// + /// HTTP版本为2.0或更高版本,或者版本策略设置为。 + /// -或者- + /// 从派生的自定义类不会重写 + /// 方法。 + /// -或者- + /// 自定义不会覆盖 + /// 方法。 + /// + /// 请求消息已由实例发送。 + /// 由于网络连接、DNS故障或服务器证书验证等潜在问题,请求失败。 + /// + /// 如果异常嵌套了:请求因超时而失败。 + /// + public static HttpResponseMessage GetPartial(this HttpClient httpClient, + [StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, long? from, long? to, string? acceptType) => + GetPartial(httpClient, new Uri(requestUri), from, to, acceptType); + + /// + /// 发起一个HTTP分段下载请求(异步)。 + /// + /// 的实例。 + /// 要发送请求的Uri。 + /// + /// 分段下载的开始字节索引(包含),如果此值为空,则形参代表获取最后多少个字节的数据。 + /// + /// + /// 分段下载结束字节索引(包含),如果此值为空,则获取从形参所代表的字节索引到最后一个字节的数据。 + /// + /// 接受的文件MIME类型,如果设置了此值,将在请求时加入请求头。 + /// 取消操作的令牌。 + /// 返回一个任务,可获取一个的实例,表示HTTP分段下载的响应消息。 + /// 请求为空。 + /// + /// HTTP版本为2.0或更高版本,或者版本策略设置为。 + /// -或者- + /// 从派生的自定义类不会重写 + /// 方法。 + /// -或者- + /// 自定义不会覆盖 + /// 方法。 + /// + /// 请求消息已由实例发送。 + /// 由于网络连接、DNS故障或服务器证书验证等潜在问题,请求失败。 + /// + /// 如果异常嵌套了:请求因超时而失败。 + /// + public static Task GetPartialAsync(this HttpClient httpClient, + [StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, long? from, long? to, string? acceptType, + CancellationToken cancellationToken = default) => + GetPartialAsync(httpClient, new Uri(requestUri), from, to, acceptType, cancellationToken); + + /// + /// 发起一个HTTP分段下载请求。 + /// + /// 的实例。 + /// 要发送请求的Uri。 + /// 的实例,代表分段下载请求头的值。 + /// 接受的文件MIME类型,如果设置了此值,将在请求时加入请求头。 + /// 的实例,表示HTTP分段下载的响应消息。 + /// 请求为空。 + /// + /// HTTP版本为2.0或更高版本,或者版本策略设置为。 + /// -或者- + /// 从派生的自定义类不会重写 + /// 方法。 + /// -或者- + /// 自定义不会覆盖 + /// 方法。 + /// + /// 请求消息已由实例发送。 + /// 由于网络连接、DNS故障或服务器证书验证等潜在问题,请求失败。 + /// + /// 如果异常嵌套了:请求因超时而失败。 + /// + public static HttpResponseMessage GetPartial(this HttpClient httpClient, + [StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, + RangeHeaderValue rangeHeaderValue, string? acceptType) => + GetPartial(httpClient, new Uri(requestUri), null, rangeHeaderValue, acceptType); + + /// + /// 发起一个HTTP分段下载请求。 + /// + /// 的实例。 + /// 要发送请求的Uri。 + /// 如果传入,覆盖Uri中的主机名。 + /// 的实例,代表分段下载请求头的值。 + /// 接受的文件MIME类型,如果设置了此值,将在请求时加入请求头。 + /// 的实例,表示HTTP分段下载的响应消息。 + /// 请求为空。 + /// + /// HTTP版本为2.0或更高版本,或者版本策略设置为。 + /// -或者- + /// 从派生的自定义类不会重写 + /// 方法。 + /// -或者- + /// 自定义不会覆盖 + /// 方法。 + /// + /// 请求消息已由实例发送。 + /// 由于网络连接、DNS故障或服务器证书验证等潜在问题,请求失败。 + /// + /// 如果异常嵌套了:请求因超时而失败。 + /// + public static HttpResponseMessage GetPartial(this HttpClient httpClient, + [StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, string hostName, + RangeHeaderValue rangeHeaderValue, string? acceptType) => + GetPartial(httpClient, new Uri(requestUri), hostName, rangeHeaderValue, acceptType); + + + /// + /// 发起一个HTTP分段下载请求(异步)。 + /// + /// 的实例。 + /// 要发送请求的Uri。 + /// 的实例,代表分段下载请求头的值。 + /// 接受的文件MIME类型,如果设置了此值,将在请求时加入请求头。 + /// 取消操作的令牌。 + /// 返回一个任务,可获取一个的实例,表示HTTP分段下载的响应消息。 + /// 请求为空。 + /// + /// HTTP版本为2.0或更高版本,或者版本策略设置为。 + /// -或者- + /// 从派生的自定义类不会重写 + /// 方法。 + /// -或者- + /// 自定义不会覆盖 + /// 方法。 + /// + /// 请求消息已由实例发送。 + /// 由于网络连接、DNS故障或服务器证书验证等潜在问题,请求失败。 + /// + /// 如果异常嵌套了:请求因超时而失败。 + /// + public static Task GetPartialAsync(this HttpClient httpClient, + [StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, RangeHeaderValue rangeHeaderValue, + string? acceptType, CancellationToken cancellationToken = default) => + GetPartialAsync(httpClient, requestUri, null, rangeHeaderValue, acceptType, cancellationToken); + + /// + /// 发起一个HTTP分段下载请求(异步)。 + /// + /// 的实例。 + /// 要发送请求的Uri。 + /// 如果传入,覆盖Uri中的主机名。 + /// 的实例,代表分段下载请求头的值。 + /// 接受的文件MIME类型,如果设置了此值,将在请求时加入请求头。 + /// 取消操作的令牌。 + /// 返回一个任务,可获取一个的实例,表示HTTP分段下载的响应消息。 + /// 请求为空。 + /// + /// HTTP版本为2.0或更高版本,或者版本策略设置为。 + /// -或者- + /// 从派生的自定义类不会重写 + /// 方法。 + /// -或者- + /// 自定义不会覆盖 + /// 方法。 + /// + /// 请求消息已由实例发送。 + /// 由于网络连接、DNS故障或服务器证书验证等潜在问题,请求失败。 + /// + /// 如果异常嵌套了:请求因超时而失败。 + /// + public static Task GetPartialAsync(this HttpClient httpClient, + [StringSyntax(StringSyntaxAttribute.Uri)] string requestUri, string? hostName, + RangeHeaderValue rangeHeaderValue, string? acceptType, CancellationToken cancellationToken = default) => + GetPartialAsync(httpClient, new Uri(requestUri), hostName, rangeHeaderValue, acceptType, cancellationToken); +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Http/HttpPartialDnsResolve.cs b/src/Starward.Core.ZipStreamDownload/Http/HttpPartialDnsResolve.cs new file mode 100644 index 000000000..c8fd884b6 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Http/HttpPartialDnsResolve.cs @@ -0,0 +1,115 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; + +namespace Starward.Core.ZipStreamDownload.Http; + +/// +/// DNS解析、IP地址测试和缓存 +/// +/// 此类的作用是保证多个实例共享一个缓存池。 +/// +/// +public class HttpPartialDnsResolve +{ + /// + /// DNS缓存(主机名与IP地址列表的线程安全字典) + /// + private readonly ConcurrentDictionary>> _hostIpAddresses = new(); + + /// + /// 获取可用的IP地址列表(TCP连接测试,异步) + /// + /// 主机名 + /// 用于测试的TCP端口号 + /// 一个任务,可用获取可用的IP地址列表 + public Task GetIpAddressesAsync(string host, int port) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(port); + ArgumentOutOfRangeException.ThrowIfGreaterThan(port, 0xffff); + var iPAddress = + _hostIpAddresses.GetOrAdd(host, h => new Lazy>(() => GetIpAddressAsyncCore(h, port))); + return iPAddress.Value; + } + + /// + /// 获取可用的IP地址列表(TCP连接测试) + /// + /// 主机名 + /// 用于测试的TCP端口号 + /// 可用的IP地址列表 + public IPAddress[] GetIpAddresses(string host, int port) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(port); + ArgumentOutOfRangeException.ThrowIfGreaterThan(port, 0xffff); + var iPAddress = + _hostIpAddresses.GetOrAdd(host, h => new Lazy>(() => GetIpAddressAsyncCore(h, port))); + return iPAddress.Value.GetAwaiter().GetResult(); + } + + /// + /// 获取可用的IP地址列表(内部,无缓存) + /// + /// 主机名 + /// >用于测试的TCP端口号 + /// 可用的IP地址列表 + private static async Task GetIpAddressAsyncCore(string host, int port) + { + Exception? lastException = null; + List? addresses = null; + try + { + addresses = ConfuseEnumerable( + await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetworkV6) + .ConfigureAwait(false)); + } + catch (SocketException e) when (e.SocketErrorCode == SocketError.NoData) + { + } + if (addresses != null) return (await TestAddressesAsync(addresses).ConfigureAwait(false)).ToArray(); + try + { + addresses = ConfuseEnumerable( + await Dns.GetHostAddressesAsync(host, AddressFamily.InterNetwork) + .ConfigureAwait(false)); + } + catch (SocketException e) when (e.SocketErrorCode == SocketError.NoData) + { + } + if (addresses != null) return (await TestAddressesAsync(addresses).ConfigureAwait(false)).ToArray(); + throw lastException ?? new SocketException((int)SocketError.HostNotFound); + + async Task> TestAddressesAsync(List testAddresses) + { + var result = new List(); + foreach (var address in testAddresses) + { + try + { + using var client = new TcpClient(); + var endPoint = new IPEndPoint(address, port); + await client.ConnectAsync(endPoint).ConfigureAwait(false); + result.Add(address); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + lastException = ex; + } + } + if (result.Count == 0) throw lastException ?? new SocketException((int)SocketError.HostNotFound); + return result; + } + } + + /// + /// 对一个迭代器对象进行随机排序 + /// + /// 一个迭代器的实例 + /// 迭代器的类型 + /// 一个已经打乱顺序的列表。 + private static List ConfuseEnumerable(IEnumerable enumerable) + { + var random = new Random(); + return enumerable.OrderBy(_ => random.Next()).ToList(); + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Http/HttpPartialDownloadStream.cs b/src/Starward.Core.ZipStreamDownload/Http/HttpPartialDownloadStream.cs new file mode 100644 index 000000000..ddef2df9e --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Http/HttpPartialDownloadStream.cs @@ -0,0 +1,253 @@ +namespace Starward.Core.ZipStreamDownload.Http; + +public abstract class HttpPartialDownloadStream : Stream +{ + public override bool CanRead => true; + + public override bool CanSeek => true; + + public override bool CanWrite => false; + + public override long Length => EndBytes - StartBytes; + + public override long Position + { + get + { + ThrowIfThisIsDisposed(); + return _fakePosition; + } + set + { + ThrowIfThisIsDisposed(); + SeekSimulated(value); + } + } + + public long StartBytes { get; protected set; } + + public long EndBytes { get; protected set; } + + public long FileLength { get; protected init; } + + public DateTimeOffset? FileLastModifiedTime { get; protected set; } + + /// + /// 当网络错误时进行自动重试的选项 + /// 取值范围(0,20),默认10 + /// + public AutoRetryOptions AutoRetryOptions { get; protected init; } + + /// + /// 标识此类释放已经释放资源 + /// + private bool _disposed; + + private long _realPosition; + + private long _fakePosition; + + public override long Seek(long offset, SeekOrigin origin) + { + ThrowIfThisIsDisposed(); + SeekSimulated(ValidateSeekArgumentsAndGetNewPosition(offset, origin)); + return _fakePosition; + } + + public override void Flush() + { + ThrowIfThisIsDisposed(); + SeekActually(); + } + + public override async Task FlushAsync(CancellationToken cancellationToken) + { + ThrowIfThisIsDisposed(); + await SeekActuallyAsync(cancellationToken).ConfigureAwait(false); + } + + public bool ResetRange(long? startBytes = null, long? endBytes = null) + { + ThrowIfThisIsDisposed(); + var result = ResetRangeCore(startBytes, endBytes); + if (result) _fakePosition = _realPosition = 0; + return result; + } + + protected abstract bool ResetRangeCore(long? startBytes = null, long? endBytes = null); + + public async Task ResetRangeAsync(long? startBytes = null, long? endBytes = null, + CancellationToken cancellationToken = default) + { + ThrowIfThisIsDisposed(); + var result = await ResetRangeAsyncCore(startBytes, endBytes, cancellationToken).ConfigureAwait(false); + if (result) _fakePosition = _realPosition = 0; + return result; + } + + protected virtual Task ResetRangeAsyncCore(long? startBytes = null, long? endBytes = null, + CancellationToken cancellationToken = default) + { + try + { + return Task.FromResult(ResetRangeCore(startBytes, endBytes)); + } + catch (Exception e) + { + return Task.FromException(e); + } + } + + protected int GetReadCount(int count) + { + if (_realPosition == Length) return 0; + if (_realPosition + count > Length) return (int)Math.Min(Length - _realPosition, int.MaxValue); + return count; + } + + private void SeekSimulated(long newPosition) + { + ArgumentOutOfRangeException.ThrowIfNegative(newPosition); + if (newPosition > Length) throw new EndOfStreamException(); + _fakePosition = newPosition; + } + + protected void SetPositionToEndActually() + { + if (_realPosition != _fakePosition) throw new InvalidOperationException(); + _realPosition = _fakePosition = Length; + } + + protected void AddPositionActually(int count = 1) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + if (_realPosition != _fakePosition) throw new InvalidOperationException(); + _fakePosition = _realPosition += count; + } + + protected void SeekActually() + { + if (!ValidateSeekActual()) return; + SeekActuallyCore(_fakePosition); + _realPosition = _fakePosition; + } + + protected async Task SeekActuallyAsync(CancellationToken cancellationToken = default) + { + if (!ValidateSeekActual()) return; + await SeekActuallyAsyncCore(_fakePosition, cancellationToken).ConfigureAwait(false); + _realPosition = _fakePosition; + } + + protected abstract void SeekActuallyCore(long fakePosition); + + protected virtual Task SeekActuallyAsyncCore(long fakePosition, CancellationToken cancellationToken = default) + { + try + { + SeekActuallyCore(fakePosition); + } + catch (Exception e) + { + Task.FromException(e); + } + return Task.CompletedTask; + } + + private bool ValidateSeekActual() + { + return _fakePosition != _realPosition && _fakePosition != Length; + } + + protected long ValidateSeekArgumentsAndGetNewPosition(long offset, SeekOrigin origin) + { + switch (origin) + { + case SeekOrigin.Begin: + if (offset < 0) throw new EndOfStreamException(); + return offset; + case SeekOrigin.Current: + if (offset < 0 && offset * -1 > Position || + offset > 0 && offset > Length - Position + ) throw new EndOfStreamException(); + return Position + offset; + case SeekOrigin.End: + if (offset > 0) throw new EndOfStreamException(); + return Length + offset; + default: + throw new ArgumentOutOfRangeException(nameof(origin), origin, null); + } + } + + protected static void ValidateStartBytesAndEndBytes(long? startBytes, long? endBytes, long? fileLength = null) + { + if (startBytes != null) + { + ArgumentOutOfRangeException.ThrowIfNegative(startBytes.Value); + if (fileLength.HasValue) + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(startBytes.Value, fileLength.Value); + } + if (endBytes != null) + ArgumentOutOfRangeException.ThrowIfNegative(endBytes.Value); + if (startBytes != null && endBytes != null) + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(startBytes.Value, endBytes.Value); + } + + protected (long, long) ValidateAndGetStartBytesAndEndBytes(long? startBytes, long? endBytes) + { + ValidateStartBytesAndEndBytes(startBytes, endBytes, FileLength); + long newStartBytes, newEndBytes; + + if (startBytes == null && endBytes == null) + { + newStartBytes = 0; + newEndBytes = FileLength; + } + else if (startBytes != null && endBytes == null) + { + newStartBytes = startBytes.Value; + newEndBytes = FileLength; + } + else if (startBytes == null && endBytes != null) + { + newStartBytes = FileLength - Math.Min(endBytes.Value, FileLength); + newEndBytes = FileLength; + } + else + { + newStartBytes = startBytes!.Value; + newEndBytes = Math.Min(endBytes!.Value, FileLength); + } + return (newStartBytes, newEndBytes); + } + + protected void ThrowIfThisIsDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + protected void SetDisposed() => _disposed = true; + + #region 不支持的方法 + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, + object? state) => throw new NotSupportedException(); + + public override void EndWrite(IAsyncResult asyncResult) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override void Write(ReadOnlySpan buffer) => throw new NotSupportedException(); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public override void WriteByte(byte value) => throw new NotSupportedException(); + + #endregion +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Http/HttpPartialDownloadStreamUri.cs b/src/Starward.Core.ZipStreamDownload/Http/HttpPartialDownloadStreamUri.cs new file mode 100644 index 000000000..da5b1facc --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Http/HttpPartialDownloadStreamUri.cs @@ -0,0 +1,125 @@ +using System.Net; + +namespace Starward.Core.ZipStreamDownload.Http; + +/// +/// HTTP部分下载流使用的自定义URI对象。 +/// +/// +/// 此类的作用是保证在第一次获取IP地址前将URL中的域名进行解析,并测试可用地址。 +/// 确保在DNS返回多个地址的整个部分下载过程中,只使用相同IP地址进行下载。 +/// 多进程下载时将请求轮询调度负载分担到DNS解析到的多个IP上。 +/// +/// 一个的实例,表示一个URI地址。 +/// 一个的实例,用于DNS解析、IP地址测试和缓存。 +public class HttpPartialDownloadStreamUri(Uri uri, HttpPartialDnsResolve httpPartialDnsResolve) +{ + /// + /// 获取对象初始化时传入的 + /// + public Uri Uri { get; } = uri; + + /// + /// 获取对象初始化时传入的中的主机名 + /// + public string Host { get; } = uri.Host; + + /// + /// 获取的实例,用于DNS解析、IP地址测试和缓存 + /// + public HttpPartialDnsResolve HttpPartialDnsResolve { get; } = httpPartialDnsResolve; + + /// + /// 已经获取IP地址的总次数 + /// + private ulong _ipAddressesRequestCount; + + /// + /// 可用的IP地址(异步懒初始化) + /// + private readonly Lazy> _ipAddresses = new (async () => + { + return uri.HostNameType switch + { + UriHostNameType.IPv4 or UriHostNameType.IPv6 => [IPAddress.Parse(uri.Host)], + UriHostNameType.Dns => await httpPartialDnsResolve.GetIpAddressesAsync(uri.Host, uri.Port) + .ConfigureAwait(false), + _ => throw new ArgumentException() + }; + }); + + /// + /// 获取一个IP地址作为Host的(异步) + /// + /// 一个任务,可获取一个IP地址作为Host的 + public async Task GetIpAddressUriAsync() + { + var ipAddresses = await _ipAddresses.Value; + var ipAddressesRequestCount = Interlocked.Increment(ref _ipAddressesRequestCount); + var ipAddress = ipAddresses[(int)(ipAddressesRequestCount % (ulong)ipAddresses.Length)]; + return GetIpAddressUri(Uri, ipAddress); + } + + /// + /// 获取一个IP地址作为Host的 + /// + /// 一个IP地址作为Host的 + public Uri GetIpAddressUri() + { + var ipAddresses = _ipAddresses.Value.GetAwaiter().GetResult(); + var ipAddressesRequestCount = Interlocked.Increment(ref _ipAddressesRequestCount); + var ipAddress = ipAddresses[(int)(ipAddressesRequestCount % (ulong)ipAddresses.Length)]; + return GetIpAddressUri(Uri, ipAddress); + } + + /// + /// 获取指定实例的规范字符串表示形式。 + /// + /// Uri实例的未转义规范表示。除了#、?和%。 + public override string ToString() => Uri.ToString(); + + public static implicit operator Uri(HttpPartialDownloadStreamUri uri) => uri.Uri; + + /// + /// HTTP部分下载流使用的自定义URI对象。 + /// + /// + /// 此类的作用是保证在第一次获取IP地址前将URL中的域名进行解析,并测试可用地址。 + /// 确保在DNS返回多个地址的整个部分下载过程中,只使用相同IP地址进行下载。 + /// 多进程下载时将请求轮询调度负载分担到DNS解析到的多个IP上。 + /// + /// 一个字符串,表示一个URI地址。 + /// 一个的实例,用于DNS解析、IP地址测试和缓存。 + public HttpPartialDownloadStreamUri(string uri, HttpPartialDnsResolve httpPartialDnsResolve) : + this(new Uri(uri), httpPartialDnsResolve) + { + + } + + /// + /// 对一个迭代器对象进行随机排序 + /// + /// 一个迭代器的实例 + /// 迭代器的类型 + /// 一个已经打乱顺序的列表。 + private static List ConfuseEnumerable(IEnumerable enumerable) + { + var random = new Random(); + return enumerable.OrderBy(_ => random.Next()).ToList(); + } + + /// + /// 获取使用IP地址作为主机名的的实例。 + /// + /// 的实例 + /// 的实例 + /// 的实例。 + private static Uri GetIpAddressUri(Uri uri, IPAddress ipAddress) + { + var uriBuilder = new UriBuilder(uri) + { + Host = ipAddress.ToString() + }; + return uriBuilder.Uri; + } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Http/ProgressReportReadStream.cs b/src/Starward.Core.ZipStreamDownload/Http/ProgressReportReadStream.cs new file mode 100644 index 000000000..f619bb058 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Http/ProgressReportReadStream.cs @@ -0,0 +1,97 @@ +namespace Starward.Core.ZipStreamDownload.Http; + +internal class ProgressReportReadStream(Stream innerStream, IProgress progress) : Stream +{ + public override bool CanRead => innerStream.CanRead; + + public override bool CanSeek => innerStream.CanSeek; + + public override bool CanWrite => false; + + public override long Length => innerStream.Length; + + public override long Position + { + get => innerStream.Position; + set + { + if (value > Length) throw new EndOfStreamException(); + innerStream.Position = value; + } + } + + private long _position; + + public override void Flush() => innerStream.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) + => innerStream.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) + { + count = innerStream.Read(buffer, offset, count); + var position = Interlocked.Add(ref _position, count); + progress.Report(position); + return count; + } + + public override int Read(Span buffer) + { + var count = innerStream.Read(buffer); + var position = Interlocked.Add(ref _position, count); + progress.Report(position); + return count; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + count = await innerStream.ReadAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); + var position = Interlocked.Add(ref _position, count); + progress.Report(position); + return count; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var count = await innerStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + var position = Interlocked.Add(ref _position, count); + progress.Report(position); + return count; + } + + public override int ReadByte() + { + var result = innerStream.ReadByte(); + if (result >= 0) + { + var position = Interlocked.Add(ref _position, 1); + progress.Report(position); + } + return result; + } + + public override long Seek(long offset, SeekOrigin origin) => innerStream.Seek(offset, origin); + + #region 不支持的方法 + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, + object? state) => throw new NotSupportedException(); + + public override void EndWrite(IAsyncResult asyncResult) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override void Write(ReadOnlySpan buffer) => throw new NotSupportedException(); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public override void WriteByte(byte value) => throw new NotSupportedException(); + + #endregion +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Http/RateLimitReadStream.cs b/src/Starward.Core.ZipStreamDownload/Http/RateLimitReadStream.cs new file mode 100644 index 000000000..50cd3b6c4 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Http/RateLimitReadStream.cs @@ -0,0 +1,147 @@ +using System.Threading.RateLimiting; + +namespace Starward.Core.ZipStreamDownload.Http; + +/// +/// 按字节下载限速的限速器的选项 +/// +public struct RateLimiterOption +{ + /// + /// 一个的实例,表示按按字节下载限速的限速器。 + /// + public required RateLimiter RateLimiter { get; init; } + + /// + /// 限速器单次最大可获取的许可数。 + /// + public required int TokenLimit + { + get => _tokenLimit; + init + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + _tokenLimit = value; + } + } + + /// + /// 限速器单次最大可获取的许可数(内部)。 + /// + private readonly int _tokenLimit; + + /// + /// 是否启用限速器。 + /// + public bool EnableRateLimiter { get; init; } +} + +internal class RateLimitReadStream(Stream innerStream, Func rateLimiterOptionBuilder) : Stream +{ + public override bool CanRead => innerStream.CanRead; + + public override bool CanSeek => innerStream.CanSeek; + + public override bool CanWrite => false; + + public override long Length => innerStream.Length; + + public override long Position + { + get => innerStream.Position; + set + { + if (value > Length) throw new EndOfStreamException(); + innerStream.Position = value; + } + } + + public override void Flush() => innerStream.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) + => innerStream.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) + { + count = WaitAcquired(count); + return innerStream.Read(buffer, offset, count); + } + + public override int Read(Span buffer) + { + var count = WaitAcquired(buffer.Length); + return innerStream.Read(buffer[..count]); + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + count = await WaitAcquiredAsync(count, cancellationToken).ConfigureAwait(false); + return await innerStream.ReadAsync(buffer, offset, count, cancellationToken).ConfigureAwait(false); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var count = await WaitAcquiredAsync(buffer.Length, cancellationToken).ConfigureAwait(false); + return await innerStream.ReadAsync(buffer[..count], cancellationToken).ConfigureAwait(false); + } + + public override int ReadByte() + { + WaitAcquired(); + return innerStream.ReadByte(); + } + + public override long Seek(long offset, SeekOrigin origin) => innerStream.Seek(offset, origin); + + private int WaitAcquired(int permitCount = 1) => WaitAcquiredAsync(permitCount).GetAwaiter().GetResult(); + + private async Task WaitAcquiredAsync(int permitCount = 1, CancellationToken cancellationToken = default) + { + while (true) + { + try + { + var rateLimiterOption = rateLimiterOptionBuilder(); + + if (!rateLimiterOption.EnableRateLimiter) return permitCount; + + var permitCountLimited = Math.Min(permitCount, rateLimiterOption.TokenLimit); + + var lease = await rateLimiterOption.RateLimiter.AcquireAsync(permitCountLimited, cancellationToken) + .ConfigureAwait(false); + if (lease.IsAcquired) return permitCountLimited; + if (lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + await Task.Delay(retryAfter, cancellationToken).ConfigureAwait(false); + else await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + catch (ArgumentOutOfRangeException) + { + break; + } + } + return permitCount; + } + + #region 不支持的方法 + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, + object? state) => throw new NotSupportedException(); + + public override void EndWrite(IAsyncResult asyncResult) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override void Write(ReadOnlySpan buffer) => throw new NotSupportedException(); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public override void WriteByte(byte value) => throw new NotSupportedException(); + + #endregion +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Http/SingleFileHttpPartialDownloadStream.cs b/src/Starward.Core.ZipStreamDownload/Http/SingleFileHttpPartialDownloadStream.cs new file mode 100644 index 000000000..fc69d09c3 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Http/SingleFileHttpPartialDownloadStream.cs @@ -0,0 +1,929 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Sockets; +using Starward.Core.ZipStreamDownload.Http.Exceptions; +using Starward.Core.ZipStreamDownload.Http.Extensions; + +namespace Starward.Core.ZipStreamDownload.Http; + +internal sealed class SingleFileHttpPartialDownloadStream : HttpPartialDownloadStream +{ + public Uri FileUri { get; } + + public HttpContentHeaders HttpContentHeaders + { + get + { + ThrowIfThisIsDisposed(); + return _responseMessage.Content.Headers; + } + } + + public HttpResponseHeaders HttpResponseHeaders + { + get + { + ThrowIfThisIsDisposed(); + return _responseMessage.Headers; + } + } + + public HttpResponseHeaders HttpTrailingHeaders + { + get + { + ThrowIfThisIsDisposed(); + return _responseMessage.TrailingHeaders; + } + } + + public Version HttpVersion + { + get + { + ThrowIfThisIsDisposed(); + return _responseMessage.Version; + } + } + + public HttpRequestMessage? HttpRequestMessage + { + get + { + ThrowIfThisIsDisposed(); + return _responseMessage.RequestMessage; + } + } + + private readonly HttpClient _httpClient; + + private readonly string? _mediaType; + + private HttpResponseMessage _responseMessage; + + private Stream _responseReadStream; + + private readonly Uri _ipAddressUri; + + private SingleFileHttpPartialDownloadStream(HttpClient httpClient, + HttpResponseMessage responseMessage, Stream responseReadStream, Uri fileUri, Uri ipAddressUri, + AutoRetryOptions autoRetryOptions, string? mediaType, long? fileLength, DateTimeOffset? fileLastModifiedTime) + { + ValidatePartialHttpResponseMessage(responseMessage, mediaType, fileLength, fileLastModifiedTime); + _httpClient = httpClient; + FileUri = fileUri; + _ipAddressUri = ipAddressUri; + AutoRetryOptions = autoRetryOptions; + _mediaType = mediaType; + _responseMessage = responseMessage; + _responseReadStream = responseReadStream; + + var contentHeaders = responseMessage.Content.Headers; + var contentRange = contentHeaders.ContentRange!; + StartBytes = contentRange.From!.Value; + EndBytes = contentRange.To!.Value + 1; + FileLength = contentRange.Length!.Value; + if (contentHeaders.LastModified.HasValue) FileLastModifiedTime = contentHeaders.LastModified.Value; + } + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes, AutoRetryOptions autoRetryOptions, + string? mediaType, long? fileLength, DateTimeOffset? fileLastModifiedTime) + { + ValidateStartBytesAndEndBytes(startBytes, endBytes, fileLength); + var ipAddressUri = fileUri.GetIpAddressUri(); + + var retryTimes = 0; + var autoRetryTimesOnNetworkError = autoRetryOptions.RetryTimesOnNetworkError; + var delayMillisecond = autoRetryOptions.DelayMillisecond; + while (true) + { + try + { + return Core(httpClient, fileUri, ipAddressUri, startBytes, endBytes + (startBytes.HasValue ? -1 : 0), + autoRetryOptions, mediaType, fileLength, fileLastModifiedTime); + } + catch (Exception e) when(e is HttpRequestException or SocketException or HttpIOException or IOException) + { + retryTimes++; + if (retryTimes > autoRetryTimesOnNetworkError) throw; + Task.Delay(delayMillisecond).GetAwaiter().GetResult(); + } + } + + static SingleFileHttpPartialDownloadStream Core(HttpClient httpClient, Uri fileUri, Uri ipAddressUri, + long? startBytes, long? endBytes, AutoRetryOptions autoRetryOptions, string? mediaType, long? fileLength, + DateTimeOffset? fileLastModifiedTime) + { + HttpResponseMessage responseMessage; + if (httpClient.DefaultRequestVersion >= System.Net.HttpVersion.Version20 || + httpClient.DefaultVersionPolicy == HttpVersionPolicy.RequestVersionOrHigher) + responseMessage = httpClient + .GetPartialAsync(ipAddressUri, fileUri.Host, startBytes, endBytes, mediaType).GetAwaiter() + .GetResult(); + else responseMessage = httpClient.GetPartial(ipAddressUri, fileUri.Host, startBytes, endBytes, mediaType); + try + { + var responseReadStream = responseMessage.Content.ReadAsStream(); + var instance = new SingleFileHttpPartialDownloadStream(httpClient, responseMessage, responseReadStream, + fileUri, ipAddressUri, autoRetryOptions, mediaType, fileLength, fileLastModifiedTime); + return instance; + } + catch + { + responseMessage.Dispose(); + throw; + } + } + } + + public static async Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes, AutoRetryOptions autoRetryOptions, + string? mediaType, long? fileLength, DateTimeOffset? fileLastModifiedTime, CancellationToken cancellationToken = default) + { + ValidateStartBytesAndEndBytes(startBytes, endBytes, fileLength); + var ipAddressUri = await fileUri.GetIpAddressUriAsync().ConfigureAwait(false); + + var retryTimes = 0; + var autoRetryTimesOnNetworkError = autoRetryOptions.RetryTimesOnNetworkError; + var delayMillisecond = autoRetryOptions.DelayMillisecond; + while (true) + { + try + { + return await CoreAsync(httpClient, fileUri, ipAddressUri, startBytes, + endBytes + (startBytes.HasValue ? -1 : 0), autoRetryOptions, mediaType, fileLength, + fileLastModifiedTime, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) when(e is HttpRequestException or SocketException or HttpIOException or IOException) + { + retryTimes++; + if (retryTimes > autoRetryTimesOnNetworkError) throw; + await Task.Delay(delayMillisecond, cancellationToken).ConfigureAwait(false); + } + } + + static async Task CoreAsync(HttpClient httpClient, Uri fileUri, + Uri ipAddressUri, long? startBytes, long? endBytes, AutoRetryOptions autoRetryOptions, string? mediaType, + long? fileLength, DateTimeOffset? fileLastModifiedTime, CancellationToken cancellationToken = default) + { + var responseMessage = await httpClient.GetPartialAsync(ipAddressUri, fileUri.Host, startBytes, endBytes, + mediaType, cancellationToken).ConfigureAwait(false); + try + { + var responseReadStream = await responseMessage.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + var instance = new SingleFileHttpPartialDownloadStream(httpClient, responseMessage, responseReadStream, + fileUri, ipAddressUri, autoRetryOptions, mediaType, fileLength, fileLastModifiedTime); + return instance; + } + catch + { + responseMessage.Dispose(); + throw; + } + } + } + + protected override bool ResetRangeCore(long? startBytes = null, long? endBytes = null) + { + var (newStartBytes, newEndBytes) = ValidateAndGetStartBytesAndEndBytes(startBytes, endBytes); + if (StartBytes == newStartBytes && EndBytes == newEndBytes) return false; + + var retryTimes = 0; + var autoRetryTimesOnNetworkError = AutoRetryOptions.RetryTimesOnNetworkError; + var delayMillisecond = AutoRetryOptions.DelayMillisecond; + while (true) + { + try + { + return Core(); + } + catch (Exception e) when(e is HttpRequestException or SocketException or HttpIOException or IOException) + { + retryTimes++; + if (retryTimes > autoRetryTimesOnNetworkError) throw; + Task.Delay(delayMillisecond).GetAwaiter().GetResult(); + } + } + + bool Core() + { + HttpResponseMessage responseMessage; + if (_httpClient.DefaultRequestVersion >= System.Net.HttpVersion.Version20 || + _httpClient.DefaultVersionPolicy == HttpVersionPolicy.RequestVersionOrHigher) + responseMessage = _httpClient.GetPartialAsync(_ipAddressUri, FileUri.Host, newStartBytes, + newEndBytes - 1, _mediaType).GetAwaiter().GetResult(); + else responseMessage = _httpClient.GetPartial(_ipAddressUri, FileUri.Host, newStartBytes, + newEndBytes - 1, _mediaType); + try + { + ValidatePartialHttpResponseMessage(responseMessage, _mediaType, FileLength, FileLastModifiedTime); + var responseReadStream = responseMessage.Content.ReadAsStream(); + try + { + _responseReadStream.Dispose(); + _responseMessage.Dispose(); + } + catch + { + responseReadStream.Dispose(); + throw; + } + _responseReadStream = responseReadStream; + } + catch + { + responseMessage.Dispose(); + throw; + } + _responseMessage = responseMessage; + var contentHeaders = responseMessage.Content.Headers; + var contentRange = contentHeaders.ContentRange!; + StartBytes = contentRange.From!.Value; + EndBytes = contentRange.To!.Value + 1; + if (FileLastModifiedTime == null && contentHeaders.LastModified != null) + FileLastModifiedTime = contentHeaders.LastModified; + return true; + } + } + + protected override async Task ResetRangeAsyncCore(long? startBytes = null, long? endBytes = null, + CancellationToken cancellationToken = default) + { + var (newStartBytes, newEndBytes) = ValidateAndGetStartBytesAndEndBytes(startBytes, endBytes); + if (StartBytes == newStartBytes && EndBytes == newEndBytes) return false; + + var retryTimes = 0; + var autoRetryTimesOnNetworkError = AutoRetryOptions.RetryTimesOnNetworkError; + var delayMillisecond = AutoRetryOptions.DelayMillisecond; + while (true) + { + try + { + return await CoreAsync().ConfigureAwait(false); + } + catch (Exception e) when(e is HttpRequestException or SocketException or HttpIOException or IOException) + { + retryTimes++; + if (retryTimes > autoRetryTimesOnNetworkError) throw; + await Task.Delay(delayMillisecond, cancellationToken).ConfigureAwait(false); + } + } + + async Task CoreAsync() + { + var responseMessage = await _httpClient.GetPartialAsync(_ipAddressUri, FileUri.Host, newStartBytes, + newEndBytes - 1, _mediaType, cancellationToken).ConfigureAwait(false); + try + { + ValidatePartialHttpResponseMessage(responseMessage, _mediaType, FileLength, FileLastModifiedTime); + var responseReadStream = await responseMessage.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + try + { + await _responseReadStream.DisposeAsync().ConfigureAwait(false); + _responseMessage.Dispose(); + } + catch + { + await responseReadStream.DisposeAsync().ConfigureAwait(false); + throw; + } + + _responseReadStream = responseReadStream; + } + catch + { + responseMessage.Dispose(); + throw; + } + + _responseMessage = responseMessage; + var contentRange = responseMessage.Content.Headers.ContentRange!; + StartBytes = contentRange.From!.Value; + EndBytes = contentRange.To!.Value + 1; + return true; + } + } + + public override void Flush() + { + ThrowIfThisIsDisposed(); + _responseReadStream.Flush(); + base.Flush(); + } + + public override async Task FlushAsync(CancellationToken cancellationToken) + { + ThrowIfThisIsDisposed(); + await _responseReadStream.FlushAsync(cancellationToken).ConfigureAwait(false); + await base.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + public override int Read(byte[] buffer, int offset, int count) + { + ThrowIfThisIsDisposed(); + ValidateBufferArguments(buffer, offset, count); + SeekActually(); + if ((count = GetReadCount(count)) == 0) return 0; + + var retryTimes = 0; + var autoRetryTimesOnNetworkError = AutoRetryOptions.RetryTimesOnNetworkError; + var delayMillisecond = AutoRetryOptions.DelayMillisecond; + while (true) + { + try + { + if (retryTimes > 0) SeekOnRetry(); + count = _responseReadStream.Read(buffer, offset, count); + break; + } + catch (Exception e) when(e is HttpRequestException or SocketException or HttpIOException or IOException) + { + retryTimes++; + if (retryTimes > autoRetryTimesOnNetworkError) throw; + Task.Delay(delayMillisecond).GetAwaiter().GetResult(); + } + } + + AddPositionActually(count); + return count; + } + + public override int Read(Span buffer) + { + ThrowIfThisIsDisposed(); + SeekActually(); + int count; + if ((count = GetReadCount(buffer.Length)) == 0) return 0; + + var retryTimes = 0; + var autoRetryTimesOnNetworkError = AutoRetryOptions.RetryTimesOnNetworkError; + var delayMillisecond = AutoRetryOptions.DelayMillisecond; + while (true) + { + try + { + if (retryTimes > 0) SeekOnRetry(); + count = _responseReadStream.Read(buffer[..count]); + break; + } + catch (Exception e) when(e is HttpRequestException or SocketException or HttpIOException or IOException) + { + retryTimes++; + if (retryTimes > autoRetryTimesOnNetworkError) throw; + Task.Delay(delayMillisecond).GetAwaiter().GetResult(); + } + } + + AddPositionActually(count); + return count; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, + CancellationToken cancellationToken) + { + ThrowIfThisIsDisposed(); + ValidateBufferArguments(buffer, offset, count); + await SeekActuallyAsync(cancellationToken).ConfigureAwait(false); + if ((count = GetReadCount(count)) == 0) return 0; + + var retryTimes = 0; + var autoRetryTimesOnNetworkError = AutoRetryOptions.RetryTimesOnNetworkError; + var delayMillisecond = AutoRetryOptions.DelayMillisecond; + while (true) + { + try + { + if (retryTimes > 0) await SeekOnRetryAsync(cancellationToken).ConfigureAwait(false); + count = await _responseReadStream.ReadAsync(buffer, offset, count, cancellationToken) + .ConfigureAwait(false); + break; + } + catch (Exception e) when(e is HttpRequestException or SocketException or HttpIOException or IOException) + { + retryTimes++; + if (retryTimes > autoRetryTimesOnNetworkError) throw; + await Task.Delay(delayMillisecond, cancellationToken).ConfigureAwait(false); + } + } + + AddPositionActually(count); + return count; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ThrowIfThisIsDisposed(); + await SeekActuallyAsync(cancellationToken).ConfigureAwait(false); + int count; + if ((count = GetReadCount(buffer.Length)) == 0) return 0; + + var retryTimes = 0; + var autoRetryTimesOnNetworkError = AutoRetryOptions.RetryTimesOnNetworkError; + var delayMillisecond = AutoRetryOptions.DelayMillisecond; + while (true) + { + try + { + if (retryTimes > 0) await SeekOnRetryAsync(cancellationToken).ConfigureAwait(false); + count = await _responseReadStream.ReadAsync(buffer[..count], cancellationToken).ConfigureAwait(false); + break; + } + catch (Exception e) when(e is HttpRequestException or SocketException or HttpIOException or IOException) + { + retryTimes++; + if (retryTimes > autoRetryTimesOnNetworkError) throw; + await Task.Delay(delayMillisecond, cancellationToken).ConfigureAwait(false); + } + } + + AddPositionActually(count); + return count; + } + + public override int ReadByte() + { + ThrowIfThisIsDisposed(); + SeekActually(); + if (Position == Length) return -1; + int byteRent; + + var retryTimes = 0; + var autoRetryTimesOnNetworkError = AutoRetryOptions.RetryTimesOnNetworkError; + var delayMillisecond = AutoRetryOptions.DelayMillisecond; + while (true) + { + try + { + if (retryTimes > 0) SeekOnRetry(); + byteRent = _responseReadStream.ReadByte(); + break; + } + catch (Exception e) when(e is HttpRequestException or SocketException or HttpIOException or IOException) + { + retryTimes++; + if (retryTimes > autoRetryTimesOnNetworkError) throw; + Task.Delay(delayMillisecond).GetAwaiter().GetResult(); + } + } + + if (byteRent >= 0) AddPositionActually(); + return byteRent; + } + + public override void CopyTo(Stream destination, int bufferSize) + { + ThrowIfThisIsDisposed(); + ValidateCopyToArguments(destination, bufferSize); + SeekActually(); + + var retryTimes = 0; + var autoRetryTimesOnNetworkError = AutoRetryOptions.RetryTimesOnNetworkError; + var delayMillisecond = AutoRetryOptions.DelayMillisecond; + while (true) + { + try + { + if (retryTimes > 0) SeekOnRetry(); + _responseReadStream.CopyTo(destination, bufferSize); + break; + } + catch (Exception e) when(e is HttpRequestException or SocketException or HttpIOException or IOException) + { + retryTimes++; + if (retryTimes > autoRetryTimesOnNetworkError) throw; + Task.Delay(delayMillisecond).GetAwaiter().GetResult(); + } + } + + SetPositionToEndActually(); + } + + public override async Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + ThrowIfThisIsDisposed(); + ValidateCopyToArguments(destination, bufferSize); + await SeekActuallyAsync(cancellationToken).ConfigureAwait(false); + + var retryTimes = 0; + var autoRetryTimesOnNetworkError = AutoRetryOptions.RetryTimesOnNetworkError; + var delayMillisecond = AutoRetryOptions.DelayMillisecond; + while (true) + { + try + { + if (retryTimes > 0) await SeekOnRetryAsync(cancellationToken).ConfigureAwait(false); + await _responseReadStream.CopyToAsync(destination, bufferSize, cancellationToken).ConfigureAwait(false); + break; + } + catch (Exception e) when(e is HttpRequestException or SocketException or HttpIOException or IOException) + { + retryTimes++; + if (retryTimes > autoRetryTimesOnNetworkError) throw; + await Task.Delay(delayMillisecond, cancellationToken).ConfigureAwait(false); + } + } + + SetPositionToEndActually(); + } + + protected override void Dispose(bool disposing) + { + SetDisposed(); + if (!disposing) return; + _responseReadStream.Dispose(); + _responseMessage.Dispose(); + } + + public override async ValueTask DisposeAsync() + { + SetDisposed(); + await _responseReadStream.DisposeAsync().ConfigureAwait(false); + _responseMessage.Dispose(); + } + + protected override void SeekActuallyCore(long fakePosition) => SeekActuallyCore(fakePosition, true); + + private void SeekActuallyCore(long fakePosition, bool retry) + { + if (!retry) + { + Core(); + return; + } + + var retryTimes = 0; + var autoRetryTimesOnNetworkError = AutoRetryOptions.RetryTimesOnNetworkError; + var delayMillisecond = AutoRetryOptions.DelayMillisecond; + while (true) + { + try + { + if (retryTimes > 0) SeekOnRetry(); + Core(); + return; + } + catch (Exception e) when(e is HttpRequestException or SocketException or HttpIOException or IOException) + { + retryTimes++; + if (retryTimes > autoRetryTimesOnNetworkError) throw; + Task.Delay(delayMillisecond).GetAwaiter().GetResult(); + } + } + + void Core() + { + HttpResponseMessage responseMessage; + if (_httpClient.DefaultRequestVersion >= System.Net.HttpVersion.Version20 || + _httpClient.DefaultVersionPolicy == HttpVersionPolicy.RequestVersionOrHigher) + responseMessage = _httpClient.GetPartialAsync(FileUri, StartBytes + fakePosition, EndBytes - 1, + _mediaType).GetAwaiter().GetResult(); + else responseMessage = _httpClient.GetPartial(FileUri, StartBytes + fakePosition, EndBytes - 1, _mediaType); + try + { + ValidatePartialHttpResponseMessage(responseMessage, _mediaType, FileLength, FileLastModifiedTime); + var responseReadStream = responseMessage.Content.ReadAsStream(); + try + { + _responseReadStream.Dispose(); + _responseMessage.Dispose(); + } + catch + { + responseReadStream.Dispose(); + throw; + } + _responseReadStream = responseReadStream; + } + catch + { + responseMessage.Dispose(); + throw; + } + _responseMessage = responseMessage; + } + } + + protected override Task SeekActuallyAsyncCore(long fakePosition, + CancellationToken cancellationToken = default) => SeekActuallyAsyncCore(fakePosition, true, cancellationToken); + + private async Task SeekActuallyAsyncCore(long fakePosition, bool retry, + CancellationToken cancellationToken = default) + { + if (!retry) + { + await CoreAsync().ConfigureAwait(false); + return; + } + + var retryTimes = 0; + var autoRetryTimesOnNetworkError = AutoRetryOptions.RetryTimesOnNetworkError; + var delayMillisecond = AutoRetryOptions.DelayMillisecond; + while (true) + { + try + { + if (retryTimes > 0) await SeekOnRetryAsync(cancellationToken).ConfigureAwait(false); + await CoreAsync().ConfigureAwait(false); + return; + } + catch (Exception e) when(e is HttpRequestException or SocketException or HttpIOException or IOException) + { + retryTimes++; + if (retryTimes > autoRetryTimesOnNetworkError) throw; + await Task.Delay(delayMillisecond, cancellationToken).ConfigureAwait(false); + } + } + + async Task CoreAsync() + { + var responseMessage = await _httpClient.GetPartialAsync(FileUri, StartBytes + fakePosition, EndBytes - 1, + _mediaType, cancellationToken).ConfigureAwait(false); + try + { + ValidatePartialHttpResponseMessage(responseMessage, _mediaType, FileLength, FileLastModifiedTime); + var responseReadStream = await responseMessage.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + try + { + await _responseReadStream.DisposeAsync().ConfigureAwait(false); + _responseMessage.Dispose(); + } + catch + { + await responseReadStream.DisposeAsync().ConfigureAwait(false); + throw; + } + + _responseReadStream = responseReadStream; + } + catch + { + responseMessage.Dispose(); + throw; + } + + _responseMessage = responseMessage; + } + } + + private void SeekOnRetry() => SeekActuallyCore(Position, false); + + private Task SeekOnRetryAsync(CancellationToken cancellationToken = default) + => SeekActuallyAsyncCore(Position, false, cancellationToken); + + private static void ValidatePartialHttpResponseMessage(HttpResponseMessage responseMessage, + string? mediaType = null, long? contentRangeLength = null, DateTimeOffset? lastModified = null) + { + responseMessage.EnsureSuccessStatusCode(); + + if (responseMessage.StatusCode == HttpStatusCode.OK) + HttpServerNotSupportedPartialDownloadException.Throw(); + + HttpStatusCodeInvalidException.ThrowIfCodeNotEqualPartialContent(responseMessage.StatusCode, + responseMessage.ReasonPhrase); + + HttpMediaTypeMismatchException.ThrowIfVersionLessThenHttp11(responseMessage.Version); + + var responseHeaders = responseMessage.Content.Headers; + + HttpMediaTypeMismatchException.ThrowIfMediaTypeMismatch(responseHeaders, mediaType); + + if (responseHeaders.ContentRange == null) + HttpServerNotSupportedPartialDownloadException.Throw(); + + var contentRange = responseHeaders.ContentRange!; + if (contentRange.Unit != "bytes" || + !contentRange.HasRange || !contentRange.HasLength || + contentRange.To is null or < 0 || contentRange.To < contentRange.From || + contentRange.To > contentRange.Length || contentRange.From > contentRange.Length) + HttpServerNotSupportedPartialDownloadException.Throw(); + + if (responseHeaders.ContentLength != null && + responseHeaders.ContentLength != contentRange.To - contentRange.From + 1) + HttpServerNotSupportedPartialDownloadException.Throw(); + + if (contentRangeLength != null && contentRangeLength != (long)contentRange.Length!) + HttpFileModifiedDuringPartialDownload.Throw(); + + if (lastModified != null && responseHeaders.LastModified != null && + lastModified != responseHeaders.LastModified) + HttpFileModifiedDuringPartialDownload.Throw(); + } + + #region 重载方法 + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, AutoRetryOptions autoRetryOptions) + => GetInstance(httpClient, fileUri, null, null, autoRetryOptions, null, null, null); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri) + => GetInstance(httpClient, fileUri, null, null, new AutoRetryOptions(), null, null, null); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes, AutoRetryOptions autoRetryOptions) + => GetInstance(httpClient, fileUri, startBytes, endBytes, autoRetryOptions, null, null, null); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes) + => GetInstance(httpClient, fileUri, startBytes, endBytes, new AutoRetryOptions(), null, null, null); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, AutoRetryOptions autoRetryOptions) + => GetInstance(httpClient, fileUri, startBytes, null, autoRetryOptions, null, null, null); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes) + => GetInstance(httpClient, fileUri, startBytes, null, new AutoRetryOptions(), null, null, null); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes, AutoRetryOptions autoRetryOptions, + string? mediaType) + => GetInstance(httpClient, fileUri, startBytes, endBytes, autoRetryOptions, mediaType, null, null); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes, string? mediaType) + => GetInstance(httpClient, fileUri, startBytes, endBytes, new AutoRetryOptions(), mediaType, null, null); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, AutoRetryOptions autoRetryOptions, string? mediaType) + => GetInstance(httpClient, fileUri, startBytes, null, autoRetryOptions, mediaType, null, null); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, string? mediaType) + => GetInstance(httpClient, fileUri, startBytes, null, new AutoRetryOptions(), mediaType, null, null); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, AutoRetryOptions autoRetryOptions, string? mediaType) + => GetInstance(httpClient, fileUri, null, null, autoRetryOptions, mediaType, null, null); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, string? mediaType) + => GetInstance(httpClient, fileUri, null, null, new AutoRetryOptions(), mediaType, null, null); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, string? mediaType, long? fileLength, AutoRetryOptions autoRetryOptions, + DateTimeOffset? fileLastModifiedTime) + => GetInstance(httpClient, fileUri, null, null, autoRetryOptions, mediaType, fileLength, fileLastModifiedTime); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, string? mediaType, long? fileLength, DateTimeOffset? fileLastModifiedTime) + => GetInstance(httpClient, fileUri, null, null, new AutoRetryOptions(), mediaType, fileLength, + fileLastModifiedTime); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes, AutoRetryOptions autoRetryOptions, + long? fileLength, DateTimeOffset? fileLastModifiedTime) + => GetInstance(httpClient, fileUri, startBytes, endBytes, autoRetryOptions, null, fileLength, + fileLastModifiedTime); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes, long? fileLength, + DateTimeOffset? fileLastModifiedTime) + => GetInstance(httpClient, fileUri, startBytes, endBytes, new AutoRetryOptions(), null, fileLength, + fileLastModifiedTime); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, AutoRetryOptions autoRetryOptions, long? fileLength, + DateTimeOffset? fileLastModifiedTime) + => GetInstance(httpClient, fileUri, startBytes, null, autoRetryOptions, null, fileLength, fileLastModifiedTime); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? fileLength, DateTimeOffset? fileLastModifiedTime) + => GetInstance(httpClient, fileUri, null, null, new AutoRetryOptions(), null, fileLength, fileLastModifiedTime); + + public static SingleFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes, string? mediaType, long? fileLength, + DateTimeOffset? fileLastModifiedTime) + => GetInstance(httpClient, fileUri, startBytes, endBytes, new AutoRetryOptions(), mediaType, fileLength, + fileLastModifiedTime); + + + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, AutoRetryOptions autoRetryOptions, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, null, null, autoRetryOptions, null, null, null, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, null, null, new AutoRetryOptions(), null, null, null, + cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes, AutoRetryOptions autoRetryOptions, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, startBytes, endBytes, autoRetryOptions, + null, null, null, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, startBytes, endBytes, new AutoRetryOptions(), null, null, null, + cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, AutoRetryOptions autoRetryOptions, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, startBytes, null, autoRetryOptions, null, null, null, + cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, startBytes, null, new AutoRetryOptions(), null, null, null, + cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes, AutoRetryOptions autoRetryOptions, + string? mediaType, CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, startBytes, endBytes, autoRetryOptions, mediaType, null, null, + cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes, string? mediaType, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, startBytes, endBytes, new AutoRetryOptions(), mediaType, null, null, + cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, AutoRetryOptions autoRetryOptions, string? mediaType, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, startBytes, null, autoRetryOptions, mediaType, null, null, + cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, string? mediaType, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, startBytes, null, new AutoRetryOptions(), mediaType, null, null, + cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, AutoRetryOptions autoRetryOptions, string? mediaType, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, null, null, autoRetryOptions, mediaType, null, null, + cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, string? mediaType, CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, null, null, new AutoRetryOptions(), mediaType, null, null, + cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, AutoRetryOptions autoRetryOptions, string? mediaType, long? fileLength, + DateTimeOffset? fileLastModifiedTime, CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, null, null, autoRetryOptions, mediaType, fileLength, + fileLastModifiedTime, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, string? mediaType, long? fileLength, DateTimeOffset? fileLastModifiedTime, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, null, null, new AutoRetryOptions(), mediaType, fileLength, + fileLastModifiedTime, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes, AutoRetryOptions autoRetryOptions, + long? fileLength, DateTimeOffset? fileLastModifiedTime, CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, startBytes, endBytes, autoRetryOptions, null, fileLength, + fileLastModifiedTime, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes, long? fileLength, + DateTimeOffset? fileLastModifiedTime, CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, startBytes, endBytes, new AutoRetryOptions(), null, fileLength, + fileLastModifiedTime, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, AutoRetryOptions autoRetryOptions, long? fileLength, + DateTimeOffset? fileLastModifiedTime, CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, startBytes, null, autoRetryOptions, null, fileLength, + fileLastModifiedTime, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? fileLength, DateTimeOffset? fileLastModifiedTime, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, startBytes, null, new AutoRetryOptions(), null, fileLength, + fileLastModifiedTime, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, AutoRetryOptions autoRetryOptions, long? fileLength, + DateTimeOffset? fileLastModifiedTime, CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, null, null, autoRetryOptions, null, fileLength, fileLastModifiedTime, + cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? fileLength, DateTimeOffset? fileLastModifiedTime, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, null, null, new AutoRetryOptions(), null, fileLength, + fileLastModifiedTime, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + HttpPartialDownloadStreamUri fileUri, long? startBytes, long? endBytes, string? mediaType, long? fileLength, + DateTimeOffset? fileLastModifiedTime, CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUri, startBytes, endBytes, new AutoRetryOptions(), mediaType, fileLength, + fileLastModifiedTime, cancellationToken); + + #endregion +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Http/VolumesFileHttpPartialDownloadStream.cs b/src/Starward.Core.ZipStreamDownload/Http/VolumesFileHttpPartialDownloadStream.cs new file mode 100644 index 000000000..5ec23a4e9 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Http/VolumesFileHttpPartialDownloadStream.cs @@ -0,0 +1,470 @@ +using System.Collections.Concurrent; +using System.Net.Http.Headers; + +namespace Starward.Core.ZipStreamDownload.Http; + +internal sealed class VolumesFileHttpPartialDownloadStream : HttpPartialDownloadStream +{ + public class SingleFileInit(HttpPartialDownloadStreamUri fileUri, long? fileLength = null, + DateTimeOffset? fileLastModifiedTime = null) + { + + public HttpPartialDownloadStreamUri FileUri { get; } = fileUri; + + public long? FileLength { get; } = fileLength; + + public DateTimeOffset? FileLastModifiedTime { get; } = fileLastModifiedTime; + } + + public class SingleFile(SingleFileHttpPartialDownloadStream singleDownloadStream) + { + + public Uri FileUri { get; } = singleDownloadStream.FileUri; + + long? FileLength { get; } = singleDownloadStream.FileLength; + + private DateTimeOffset? FileLastModifiedTime { get; } = singleDownloadStream.FileLastModifiedTime; + + public HttpContentHeaders HttpContentHeaders { get; } = singleDownloadStream.HttpContentHeaders; + + public HttpResponseHeaders HttpResponseHeaders { get; } = singleDownloadStream.HttpResponseHeaders; + + public HttpResponseHeaders HttpTrailingHeaders { get; } = singleDownloadStream.HttpTrailingHeaders; + + public Version HttpVersion { get; } = singleDownloadStream.HttpVersion; + + public HttpRequestMessage? HttpRequestMessage { get; } = singleDownloadStream.HttpRequestMessage; + } + + public IReadOnlyCollection Files { get; } + + private readonly SingleFileHttpPartialDownloadStream[] _singleDownloadStreamArray; + + private SingleFileHttpPartialDownloadStream[] _singleDownloadStreamStartToEndArray = null!; + + private VolumesFileHttpPartialDownloadStream( + SingleFileHttpPartialDownloadStream[] singleDownloadStreamArray, + long? startBytes, long? endBytes, AutoRetryOptions autoRetryOptions) + { + _singleDownloadStreamArray = singleDownloadStreamArray; + Files = singleDownloadStreamArray.Select(s => new SingleFile(s)).ToList(); + FileLength = singleDownloadStreamArray.Sum(s => s.FileLength); + AutoRetryOptions = autoRetryOptions; + + (StartBytes, EndBytes) = ValidateAndGetStartBytesAndEndBytes(startBytes, endBytes); + } + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable files, long? startBytes, long? endBytes, AutoRetryOptions autoRetryOptions, + string? mediaType) + { + var singleDownloadStreamArray = files.AsParallel() + .WithDegreeOfParallelism(10) + .Select(f => SingleFileHttpPartialDownloadStream + .GetInstance(httpClient, f.FileUri, 0, 1, autoRetryOptions, mediaType, f.FileLength, + f.FileLastModifiedTime)) + .AsOrdered() + .ToArray(); + var instance = new VolumesFileHttpPartialDownloadStream(singleDownloadStreamArray, startBytes, endBytes, + autoRetryOptions); + instance._singleDownloadStreamStartToEndArray = + instance.SeekFilesAndGetStreamSlice(instance.StartBytes, instance.EndBytes).ToArray(); + return instance; + } + + public static async Task GetInstanceAsync(HttpClient httpClient, + IEnumerable files, long? startBytes, long? endBytes, AutoRetryOptions autoRetryOptions, + string? mediaType, CancellationToken cancellationToken = default) + { + var fileArray = files.ToArray(); + var streamWithIndex = + new ConcurrentQueue<(int, SingleFileHttpPartialDownloadStream)>(); + await Parallel.ForAsync(0, fileArray.Length, new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = 10 + }, async (index, token) => + { + var file = fileArray[index]; + var stream = await SingleFileHttpPartialDownloadStream + .GetInstanceAsync(httpClient, file.FileUri, 0, 1, autoRetryOptions, mediaType, file.FileLength, + file.FileLastModifiedTime, token).ConfigureAwait(false); + streamWithIndex.Enqueue((index, stream)); + }).ConfigureAwait(false); + var singleDownloadStreamList = streamWithIndex + .OrderBy(s => s.Item1) + .Select(s => s.Item2) + .ToArray(); + var instance = new VolumesFileHttpPartialDownloadStream(singleDownloadStreamList, startBytes, endBytes, + autoRetryOptions); + instance._singleDownloadStreamStartToEndArray = (await instance.SeekFilesAndGetStreamSliceAsync + (instance.StartBytes, instance.EndBytes, cancellationToken).ConfigureAwait(false)).ToArray(); + return instance; + } + + protected override bool ResetRangeCore(long? startBytes = null, long? endBytes = null) + { + var (newStartBytes, newEndBytes) = ValidateAndGetStartBytesAndEndBytes(startBytes, endBytes); + if (StartBytes == newStartBytes && EndBytes == newEndBytes) return false; + _singleDownloadStreamStartToEndArray = SeekFilesAndGetStreamSlice(newStartBytes, newEndBytes).ToArray(); + StartBytes = newStartBytes; + EndBytes = newEndBytes; + return true; + } + + protected override async Task ResetRangeAsyncCore(long? startBytes = null, long? endBytes = null, + CancellationToken cancellationToken = default) + { + var (newStartBytes, newEndBytes) = ValidateAndGetStartBytesAndEndBytes(startBytes, endBytes); + if (StartBytes == newStartBytes && EndBytes == newEndBytes) return false; + _singleDownloadStreamStartToEndArray = (await SeekFilesAndGetStreamSliceAsync(newStartBytes, newEndBytes, + cancellationToken).ConfigureAwait(false)).ToArray(); + StartBytes = newStartBytes; + EndBytes = newEndBytes; + return true; + } + + public override int Read(byte[] buffer, int offset, int count) + { + ThrowIfThisIsDisposed(); + ValidateBufferArguments(buffer, offset, count); + SeekActually(); + var needCount = GetReadCount(count); + long endPosition = 0; + foreach (var stream in _singleDownloadStreamStartToEndArray) + { + var startPosition = endPosition; + endPosition += stream.Length; + if (Position < startPosition || Position >= endPosition) continue; + var singleCount = stream.Read(buffer, offset, needCount); + AddPositionActually(singleCount); + return singleCount; + } + return 0; + } + + public override int Read(Span buffer) + { + ThrowIfThisIsDisposed(); + SeekActually(); + var needCount = GetReadCount(buffer.Length); + long endPosition = 0; + foreach (var stream in _singleDownloadStreamStartToEndArray) + { + var startPosition = endPosition; + endPosition += stream.Length; + if (Position < startPosition || Position >= endPosition) continue; + var singleCount = stream.Read(buffer[..needCount]); + AddPositionActually(singleCount); + return singleCount; + } + return 0; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ThrowIfThisIsDisposed(); + await SeekActuallyAsync(cancellationToken).ConfigureAwait(false); + var needCount = GetReadCount(buffer.Length); + long endPosition = 0; + foreach (var stream in _singleDownloadStreamStartToEndArray) + { + var startPosition = endPosition; + endPosition += stream.Length; + if (Position < startPosition || Position >= endPosition) continue; + var singleCount = await stream.ReadAsync(buffer[..needCount], cancellationToken).ConfigureAwait(false); + AddPositionActually(singleCount); + return singleCount; + } + return 0; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ThrowIfThisIsDisposed(); + ValidateBufferArguments(buffer, offset, count); + await SeekActuallyAsync(cancellationToken).ConfigureAwait(false); + var needCount = GetReadCount(buffer.Length); + long endPosition = 0; + foreach (var stream in _singleDownloadStreamStartToEndArray) + { + var startPosition = endPosition; + endPosition += stream.Length; + if (Position < startPosition || Position >= endPosition) continue; + var singleCount = await stream.ReadAsync(buffer, offset, needCount, cancellationToken) + .ConfigureAwait(false); + AddPositionActually(singleCount); + return singleCount; + } + return 0; + } + + protected override void Dispose(bool disposing) + { + SetDisposed(); + if (!disposing) return; + Parallel.ForEach(_singleDownloadStreamArray, new ParallelOptions + { + MaxDegreeOfParallelism = 10 + }, stream => stream.Dispose()); + } + + public override async ValueTask DisposeAsync() + { + SetDisposed(); + await Parallel.ForEachAsync(_singleDownloadStreamArray, new ParallelOptions + { + MaxDegreeOfParallelism = 10 + }, async (stream, _) => await stream.DisposeAsync().ConfigureAwait(false)) + .ConfigureAwait(false); + } + + protected override void SeekActuallyCore(long fakePosition) + { + long endPosition = 0; + foreach (var stream in _singleDownloadStreamStartToEndArray) + { + var startPosition = endPosition; + endPosition += stream.Length; + if (fakePosition < startPosition || fakePosition >= endPosition) continue; + stream.Position = fakePosition - startPosition; + stream.Flush(); + } + } + + protected override async Task SeekActuallyAsyncCore(long fakePosition, + CancellationToken cancellationToken = default) + { + long endPosition = 0; + foreach (var stream in _singleDownloadStreamStartToEndArray) + { + var startPosition = endPosition; + endPosition += stream.Length; + if (fakePosition < startPosition || fakePosition >= endPosition) continue; + stream.Position = fakePosition - startPosition; + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + + private Span SeekFilesAndGetStreamSlice(long startBytes, long endBytes) + { + long endPosition = 0; + int firstFileIndex = -1, lastFileIndex = -1; + for (var index = 0; index < _singleDownloadStreamArray.Length; index++) + { + var singleDownloadStream = _singleDownloadStreamArray[index]; + var startPosition = endPosition; + endPosition += singleDownloadStream.FileLength; + + if (startBytes >= startPosition && endBytes <= endPosition) + { + singleDownloadStream.ResetRange(startBytes - startPosition, endBytes - startPosition); + firstFileIndex = index; + lastFileIndex = index; + break; + } + if (endBytes >= startPosition && endBytes <= endPosition) + { + singleDownloadStream.ResetRange(0, endBytes - startPosition); + lastFileIndex = index; + break; + } + if (startBytes >= startPosition && startBytes <= endPosition) + { + singleDownloadStream.ResetRange(startBytes - startPosition); + firstFileIndex = index; + } + else if (startBytes < startPosition && endBytes > endPosition) + { + singleDownloadStream.ResetRange(); + } + } + return _singleDownloadStreamArray.AsSpan()[firstFileIndex..(lastFileIndex + 1)]; + } + + private async Task> SeekFilesAndGetStreamSliceAsync( + long startBytes, long endBytes, CancellationToken cancellationToken = default) + { + long endPosition = 0; + int firstFileIndex = -1, lastFileIndex = -1; + for (var index = 0; index < _singleDownloadStreamArray.Length; index++) + { + var singleDownloadStream = _singleDownloadStreamArray[index]; + var startPosition = endPosition; + endPosition += singleDownloadStream.FileLength; + + if (startBytes >= startPosition && endBytes <= endPosition) + { + await singleDownloadStream.ResetRangeAsync(startBytes - startPosition, endBytes - startPosition, + cancellationToken).ConfigureAwait(false); + firstFileIndex = index; + lastFileIndex = index; + break; + } + if (endBytes > startPosition && endBytes <= endPosition) + { + await singleDownloadStream.ResetRangeAsync(0, endBytes - startPosition, cancellationToken) + .ConfigureAwait(false); + lastFileIndex = index; + break; + } + if (startBytes >= startPosition && startBytes < endPosition) + { + await singleDownloadStream.ResetRangeAsync(startBytes - startPosition, + cancellationToken: cancellationToken).ConfigureAwait(false); + firstFileIndex = index; + } + else if (startBytes < startPosition && endBytes > endPosition) + { + await singleDownloadStream.ResetRangeAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + return _singleDownloadStreamArray.AsMemory()[firstFileIndex..(lastFileIndex + 1)]; + } + + #region 重载方法 + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable files, long? startBytes, long? endBytes, AutoRetryOptions autoRetryOptions) + => GetInstance(httpClient, files, startBytes, endBytes, autoRetryOptions, null); + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable files, long? startBytes, long? endBytes) + => GetInstance(httpClient, files, startBytes, endBytes, new AutoRetryOptions(), null); + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable files, AutoRetryOptions autoRetryOptions, string? mediaType) + => GetInstance(httpClient, files, null, null, autoRetryOptions, mediaType); + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable files, string? mediaType) + => GetInstance(httpClient, files, null, null, new AutoRetryOptions(), mediaType); + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable files, AutoRetryOptions autoRetryOptions) + => GetInstance(httpClient, files, null, null, autoRetryOptions, null); + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable files) + => GetInstance(httpClient, files, null, null, new AutoRetryOptions(), null); + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable fileUris, long? startBytes, long? endBytes, + AutoRetryOptions autoRetryOptions, string? mediaType) + => GetInstance(httpClient, fileUris.Select(f => new SingleFileInit(f)), startBytes, endBytes, autoRetryOptions, + mediaType); + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable fileUris, long? startBytes, long? endBytes, string? mediaType) + => GetInstance(httpClient, fileUris.Select(f => new SingleFileInit(f)), startBytes, endBytes, + new AutoRetryOptions(), mediaType); + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable fileUris, long? startBytes, long? endBytes, + AutoRetryOptions autoRetryOptions) + => GetInstance(httpClient, fileUris, startBytes, endBytes, autoRetryOptions, null); + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable fileUris, long? startBytes, long? endBytes) + => GetInstance(httpClient, fileUris, startBytes, endBytes, new AutoRetryOptions(), null); + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable fileUris, AutoRetryOptions autoRetryOptions, string? mediaType) + => GetInstance(httpClient, fileUris, null, null, autoRetryOptions, mediaType); + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable fileUris, string? mediaType) + => GetInstance(httpClient, fileUris, null, null, new AutoRetryOptions(), mediaType); + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable fileUris, AutoRetryOptions autoRetryOptions) + => GetInstance(httpClient, fileUris, null, null, autoRetryOptions, null); + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable fileUris) + => GetInstance(httpClient, fileUris, null, null, new AutoRetryOptions(), null); + + public static VolumesFileHttpPartialDownloadStream GetInstance(HttpClient httpClient, + IEnumerable files, long? startBytes, long? endBytes, string? mediaType) + => GetInstance(httpClient, files, startBytes, endBytes, new AutoRetryOptions(), mediaType); + + + + public static Task GetInstanceAsync(HttpClient httpClient, + IEnumerable files, long? startBytes, long? endBytes, AutoRetryOptions autoRetryOptions, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, files, startBytes, endBytes, autoRetryOptions, null, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + IEnumerable files, long? startBytes, long? endBytes, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, files, startBytes, endBytes, new AutoRetryOptions(), null, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + IEnumerable files, AutoRetryOptions autoRetryOptions, string? mediaType, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, files, null, null, autoRetryOptions, mediaType, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + IEnumerable files, string? mediaType, CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, files, null, null, new AutoRetryOptions(), mediaType, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + IEnumerable files, AutoRetryOptions autoRetryOptions, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, files, null, null, autoRetryOptions, null, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + IEnumerable files, CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, files, null, null, new AutoRetryOptions(), null, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + IEnumerable fileUris, long? startBytes, long? endBytes, + AutoRetryOptions autoRetryOptions, string? mediaType, CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUris.Select(f => new SingleFileInit(f)), startBytes, endBytes, + autoRetryOptions, mediaType, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + IEnumerable fileUris, long? startBytes, long? endBytes, string? mediaType, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUris.Select(f => new SingleFileInit(f)), startBytes, endBytes, + new AutoRetryOptions(), mediaType, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + IEnumerable fileUris, long? startBytes, long? endBytes, + AutoRetryOptions autoRetryOptions, CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUris, startBytes, endBytes, autoRetryOptions, null, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + IEnumerable fileUris, long? startBytes, long? endBytes, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUris, startBytes, endBytes, new AutoRetryOptions(), null, + cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + IEnumerable fileUris, AutoRetryOptions autoRetryOptions, string? mediaType, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUris, null, null, autoRetryOptions, mediaType, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + IEnumerable fileUris, string? mediaType, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUris, null, null, new AutoRetryOptions(), mediaType, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + IEnumerable fileUris, AutoRetryOptions autoRetryOptions, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUris, null, null, autoRetryOptions, null, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + IEnumerable fileUris, CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, fileUris, null, null, new AutoRetryOptions(), null, cancellationToken); + + public static Task GetInstanceAsync(HttpClient httpClient, + IEnumerable files, long? startBytes, long? endBytes, string? mediaType, + CancellationToken cancellationToken = default) + => GetInstanceAsync(httpClient, files, startBytes, endBytes, new AutoRetryOptions(), mediaType, + cancellationToken); + + #endregion +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/IZipFileDownloadFactory.cs b/src/Starward.Core.ZipStreamDownload/IZipFileDownloadFactory.cs new file mode 100644 index 000000000..a3ba56261 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/IZipFileDownloadFactory.cs @@ -0,0 +1,26 @@ +using Starward.Core.ZipStreamDownload.Http; + +namespace Starward.Core.ZipStreamDownload; + +/// +/// ZIP文件下载对象的工厂。 +/// +public interface IZipFileDownloadFactory +{ + /// + /// ZIP文件URL的URI对象。 + /// + /// 用于FastZipStreamDownload获取ZIP文件名 + public Uri? ZipFileUri { get; } + + /// + /// 获取的新实例。 + /// + /// 的实例 + ZipFileDownload GetInstance(); + + /// + /// 获取或设置一个一个返回实例的委托,表示按字节下载限速的限速器的选项。 + /// + public Func? DownloadBytesRateLimiterOptionBuilder { get; set; } +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.Designer.cs b/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.Designer.cs new file mode 100644 index 000000000..3025dc41f --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.Designer.cs @@ -0,0 +1,167 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Starward.Core.ZipStreamDownload.Resources { + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class ExceptionMessages { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal ExceptionMessages() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Starward.Core.ZipStreamDownload.Resources.ExceptionMessages", typeof(ExceptionMessages).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to File CRC check failed: {0}. + /// + internal static string CrcVerificationFailedExceptionMessage { + get { + return ResourceManager.GetString("CrcVerificationFailedExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Attempted to access a path that is not on the disk: {0}. + /// + internal static string DirectoryNotFoundExceptionMessage { + get { + return ResourceManager.GetString("DirectoryNotFoundExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This compressed file uses features that are not supported by this library, reason: {0}. + /// + internal static string FeatureNotSupportedExceptionMessage { + get { + return ResourceManager.GetString("FeatureNotSupportedExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The actual size of the body does not match the parameters in the header.. + /// + internal static string HttpBodyLengthNotMatchExceptionMessage { + get { + return ResourceManager.GetString("HttpBodyLengthNotMatchExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The file on HTTP server are modified during partial download.. + /// + internal static string HttpFileModifiedDuringPartialDownloadMessage { + get { + return ResourceManager.GetString("HttpFileModifiedDuringPartialDownloadMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The media type returned by the HTTP server is not the required file type.. + /// + internal static string HttpMediaTypeMismatchExceptionMessage { + get { + return ResourceManager.GetString("HttpMediaTypeMismatchExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This HTTP server may not support partial download.. + /// + internal static string HttpServerNotSupportedPartialDownloadExceptionMessage { + get { + return ResourceManager.GetString("HttpServerNotSupportedPartialDownloadExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The response code returned by the HTTP server is invalid, code: {0}, message: {1}. + /// + internal static string HttpStatusCodeInvalidExceptionMessage { + get { + return ResourceManager.GetString("HttpStatusCodeInvalidExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The name in the ZIP local file header is invalid, unable to find the file name: {0}. + /// + internal static string ZipEntryFileNameNotFoundExceptionMessage { + get { + return ResourceManager.GetString("ZipEntryFileNameNotFoundExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ZipEntry must be a file entry.. + /// + internal static string ZipEntryNotIsFileArgumentExceptionMessage { + get { + return ResourceManager.GetString("ZipEntryNotIsFileArgumentExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ZIP central directory record test failed, reason: {0}. + /// + internal static string ZipFileTestFailedExceptionCentralDirectoryMessage { + get { + return ResourceManager.GetString("ZipFileTestFailedExceptionCentralDirectoryMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ZIP file test failed: {0}, reason: {1}. + /// + internal static string ZipFileTestFailedExceptionMessage { + get { + return ResourceManager.GetString("ZipFileTestFailedExceptionMessage", resourceCulture); + } + } + } +} diff --git a/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.en-US.Designer.cs b/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.en-US.Designer.cs new file mode 100644 index 000000000..335b8e902 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.en-US.Designer.cs @@ -0,0 +1,170 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Starward.Core.ZipStreamDownload.Resources { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class ExceptionMessages_en_US { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal ExceptionMessages_en_US() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Starward.Core.ZipStreamDownload.Resources.ExceptionMessages.en-US", typeof(ExceptionMessages_en_US).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to File CRC check failed: {0}. + /// + internal static string CrcVerificationFailedExceptionMessage { + get { + return ResourceManager.GetString("CrcVerificationFailedExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Attempted to access a path that is not on the disk: {0}. + /// + internal static string DirectoryNotFoundExceptionMessage { + get { + return ResourceManager.GetString("DirectoryNotFoundExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This compressed file uses features that are not supported by this library, reason: {0}. + /// + internal static string FeatureNotSupportedExceptionMessage { + get { + return ResourceManager.GetString("FeatureNotSupportedExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The actual size of the http body does not match the parameters in the http header.. + /// + internal static string HttpBodyLengthNotMatchExceptionMessage { + get { + return ResourceManager.GetString("HttpBodyLengthNotMatchExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The file on HTTP server are modified during partial download.. + /// + internal static string HttpFileModifiedDuringPartialDownloadMessage { + get { + return ResourceManager.GetString("HttpFileModifiedDuringPartialDownloadMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The media type returned by the HTTP server is not the required file type.. + /// + internal static string HttpMediaTypeMismatchExceptionMessage { + get { + return ResourceManager.GetString("HttpMediaTypeMismatchExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This HTTP server may not support partial download.. + /// + internal static string HttpServerNotSupportedPartialDownloadExceptionMessage { + get { + return ResourceManager.GetString("HttpServerNotSupportedPartialDownloadExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The response code returned by the HTTP server is invalid, code: {0}, message: {1}. + /// + internal static string HttpStatusCodeInvalidExceptionMessage { + get { + return ResourceManager.GetString("HttpStatusCodeInvalidExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The name in the ZIP local file header is invalid, unable to find the file name: {0}. + /// + internal static string ZipEntryFileNameNotFoundExceptionMessage { + get { + return ResourceManager.GetString("ZipEntryFileNameNotFoundExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ZipEntry must be a file entry.. + /// + internal static string ZipEntryNotIsFileArgumentExceptionMessage { + get { + return ResourceManager.GetString("ZipEntryNotIsFileArgumentExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ZIP central directory record test failed, reason: {0}. + /// + internal static string ZipFileTestFailedExceptionCentralDirectoryMessage { + get { + return ResourceManager.GetString("ZipFileTestFailedExceptionCentralDirectoryMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ZIP file test failed: {0}, reason: {1}. + /// + internal static string ZipFileTestFailedExceptionMessage { + get { + return ResourceManager.GetString("ZipFileTestFailedExceptionMessage", resourceCulture); + } + } + } +} diff --git a/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.en-US.resx b/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.en-US.resx new file mode 100644 index 000000000..698c2a48a --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.en-US.resx @@ -0,0 +1,57 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Attempted to access a path that is not on the disk: {0} + + + The name in the ZIP local file header is invalid, unable to find the file name: {0} + + + File CRC check failed: {0} + + + ZIP file test failed: {0}, reason: {1} + + + This compressed file uses features that are not supported by this library, reason: {0} + + + ZIP central directory record test failed, reason: {0} + + + This HTTP server may not support partial download. + + + The response code returned by the HTTP server is invalid, code: {0}, message: {1} + + + The media type returned by the HTTP server is not the required file type. + + + The file on HTTP server are modified during partial download. + + + ZipEntry must be a file entry. + + + The actual size of the http body does not match the parameters in the http header. + + \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.resx b/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.resx new file mode 100644 index 000000000..dd283286f --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.resx @@ -0,0 +1,57 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Attempted to access a path that is not on the disk: {0} + + + The name in the ZIP local file header is invalid, unable to find the file name: {0} + + + File CRC check failed: {0} + + + ZIP file test failed: {0}, reason: {1} + + + This compressed file uses features that are not supported by this library, reason: {0} + + + ZIP central directory record test failed, reason: {0} + + + This HTTP server may not support partial download. + + + The response code returned by the HTTP server is invalid, code: {0}, message: {1} + + + The media type returned by the HTTP server is not the required file type. + + + The file on HTTP server are modified during partial download. + + + ZipEntry must be a file entry. + + + The actual size of the body does not match the parameters in the header. + + \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.zh-CN.Designer.cs b/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.zh-CN.Designer.cs new file mode 100644 index 000000000..dc9cf5d55 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.zh-CN.Designer.cs @@ -0,0 +1,170 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Starward.Core.ZipStreamDownload.Resources { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class ExceptionMessages_zh_CN { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal ExceptionMessages_zh_CN() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Starward.Core.ZipStreamDownload.Resources.ExceptionMessages.zh-CN", typeof(ExceptionMessages_zh_CN).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to 文件CRC校验失败:{0}. + /// + internal static string CrcVerificationFailedExceptionMessage { + get { + return ResourceManager.GetString("CrcVerificationFailedExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to 试图访问不在磁盘上的路径:{0}. + /// + internal static string DirectoryNotFoundExceptionMessage { + get { + return ResourceManager.GetString("DirectoryNotFoundExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to 该压缩文件使用了不受此库支持的特性,原因:{0}. + /// + internal static string FeatureNotSupportedExceptionMessage { + get { + return ResourceManager.GetString("FeatureNotSupportedExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to HTTP请求体的实际大小与请求头中的相关参数不匹配。. + /// + internal static string HttpBodyLengthNotMatchExceptionMessage { + get { + return ResourceManager.GetString("HttpBodyLengthNotMatchExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to HTTP服务器上的文件在分段下载时被修改了。. + /// + internal static string HttpFileModifiedDuringPartialDownloadMessage { + get { + return ResourceManager.GetString("HttpFileModifiedDuringPartialDownloadMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to HTTP服务器返回的文件类型不是当前需要的文件类型。. + /// + internal static string HttpMediaTypeMismatchExceptionMessage { + get { + return ResourceManager.GetString("HttpMediaTypeMismatchExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to 该HTTP服务器可能不支持分段下载功能。. + /// + internal static string HttpServerNotSupportedPartialDownloadExceptionMessage { + get { + return ResourceManager.GetString("HttpServerNotSupportedPartialDownloadExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to HTTP服务器返回的响应代码无效,无效代码:{0},描述:{1}. + /// + internal static string HttpStatusCodeInvalidExceptionMessage { + get { + return ResourceManager.GetString("HttpStatusCodeInvalidExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ZIP实体文件头中的名称无效,无法找到文件名称:{0}. + /// + internal static string ZipEntryFileNameNotFoundExceptionMessage { + get { + return ResourceManager.GetString("ZipEntryFileNameNotFoundExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ZipEntry实例必须为文件。. + /// + internal static string ZipEntryNotIsFileArgumentExceptionMessage { + get { + return ResourceManager.GetString("ZipEntryNotIsFileArgumentExceptionMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ZIP实体文件头测试失败,原因:{0}. + /// + internal static string ZipFileTestFailedExceptionCentralDirectoryMessage { + get { + return ResourceManager.GetString("ZipFileTestFailedExceptionCentralDirectoryMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ZIP文件测试失败:{0},原因:{1}. + /// + internal static string ZipFileTestFailedExceptionMessage { + get { + return ResourceManager.GetString("ZipFileTestFailedExceptionMessage", resourceCulture); + } + } + } +} diff --git a/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.zh-CN.resx b/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.zh-CN.resx new file mode 100644 index 000000000..a362da28e --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Resources/ExceptionMessages.zh-CN.resx @@ -0,0 +1,57 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 试图访问不在磁盘上的路径:{0} + + + ZIP实体文件头中的名称无效,无法找到文件名称:{0} + + + 文件CRC校验失败:{0} + + + ZIP文件测试失败:{0},原因:{1} + + + 该压缩文件使用了不受此库支持的特性,原因:{0} + + + ZIP实体文件头测试失败,原因:{0} + + + 该HTTP服务器可能不支持分段下载功能。 + + + HTTP服务器返回的响应代码无效,无效代码:{0},描述:{1} + + + HTTP服务器返回的文件类型不是当前需要的文件类型。 + + + HTTP服务器上的文件在分段下载时被修改了。 + + + ZipEntry实例必须为文件。 + + + HTTP请求体的实际大小与请求头中的相关参数不匹配。 + + \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/SingleFileZipFileDownloadFactory.cs b/src/Starward.Core.ZipStreamDownload/SingleFileZipFileDownloadFactory.cs new file mode 100644 index 000000000..d9d0596f2 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/SingleFileZipFileDownloadFactory.cs @@ -0,0 +1,57 @@ +using System.Diagnostics.CodeAnalysis; +using Starward.Core.ZipStreamDownload.Http; + +namespace Starward.Core.ZipStreamDownload; + +/// +/// 用于单文件下载的的工厂 +/// +/// 的实例,用于文件下载。 +public class SingleFileZipFileDownloadFactory(HttpClient httpClient) : IZipFileDownloadFactory +{ + /// + /// ZIP文件URL。 + /// + [StringSyntax(StringSyntaxAttribute.Uri)] + public string? ZipFileUrl + { + get => _zipFileUri?.ToString(); + set => _zipFileUri = value == null ? null : new HttpPartialDownloadStreamUri(value, _httpPartialDnsResolve); + } + + /// + /// ZIP文件URL的URI对象。 + /// + public Uri? ZipFileUri => _zipFileUri?.Uri; + + /// + /// 获取或设置一个一个返回实例的委托,表示按字节下载限速的限速器的选项。 + /// + public Func? DownloadBytesRateLimiterOptionBuilder { get; set; } + + /// + /// 自动重试选项 + /// + public AutoRetryOptions AutoRetryOptions { get; } = new(); + + /// + /// ZIP文件URL(内部,类型)。 + /// + private HttpPartialDownloadStreamUri? _zipFileUri; + + /// + /// 一个的实例,用于DNS解析、IP地址测试和缓存。 + /// + private readonly HttpPartialDnsResolve _httpPartialDnsResolve = new(); + + /// + /// 获取一个用于单文件下载的类的新实例。 + /// + /// 的实例 + /// 当属性ZipFileUrl为空时引发此异常。 + public ZipFileDownload GetInstance() + => new(async (startBytes, endBytes) => + await SingleFileHttpPartialDownloadStream.GetInstanceAsync(httpClient, + _zipFileUri ?? throw new InvalidOperationException(), startBytes, endBytes, AutoRetryOptions, + ZipFileDownload.MediaType).ConfigureAwait(false), DownloadBytesRateLimiterOptionBuilder); +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/Starward.Core.ZipStreamDownload.csproj b/src/Starward.Core.ZipStreamDownload/Starward.Core.ZipStreamDownload.csproj new file mode 100644 index 000000000..103a5a8d1 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/Starward.Core.ZipStreamDownload.csproj @@ -0,0 +1,51 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + ResXFileCodeGenerator + ExceptionMessages.zh-hans.Designer.cs + + + ExceptionMessages.zh-Hans.Designer.cs + + + ResXFileCodeGenerator + ExceptionMessages.Designer.cs + + + ResXFileCodeGenerator + ExceptionMessages.en.Designer.cs + + + + + + True + True + ExceptionMessages.zh-CN.resx + + + True + True + ExceptionMessages.resx + + + True + True + ExceptionMessages.en-US.resx + + + + diff --git a/src/Starward.Core.ZipStreamDownload/VolumesFileZipFileDownloadFactory.cs b/src/Starward.Core.ZipStreamDownload/VolumesFileZipFileDownloadFactory.cs new file mode 100644 index 000000000..6ac8d2338 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/VolumesFileZipFileDownloadFactory.cs @@ -0,0 +1,80 @@ +using Starward.Core.ZipStreamDownload.Http; + +namespace Starward.Core.ZipStreamDownload; + +/// +/// 用于分卷件下载的的工厂 +/// +/// 的实例,用于文件下载。 +public class VolumesFileZipFileDownloadFactory(HttpClient httpClient) : IZipFileDownloadFactory +{ + /// + /// 分卷ZIP文件的URL列表。 + /// + /// 必须按照分卷顺序传入。 + public List? ZipFileUrlList + { + get => _zipFileUriList?.Select(u => u.ToString()).ToList(); + set => _zipFileUriList = value?.Select(u => new HttpPartialDownloadStreamUri(u, _httpPartialDnsResolve)) + .ToList(); + } + + /// + /// 分卷ZIP文件的URL的URI对象的列表。 + /// + /// 必须按照分卷顺序传入。 + public List? ZipFileUriList => _zipFileUriList?.Select(u => u.Uri).ToList(); + + /// + /// ZIP文件URL的URI对象。 + /// + /// 用于FastZipStreamDownload获取ZIP文件名 + public Uri? ZipFileUri + { + get + { + var url = _zipFileUriList?.FirstOrDefault()?.ToString(); + if (url == null) return null; + var querySplit = url.IndexOf('?'); //去除Query。 + var query = ""; + if (querySplit >= 0) + { + query = url[querySplit..]; + url = url[..querySplit]; + } + if (url.EndsWith(".001")) url = url[..^4]; //删除.001后缀 + return new Uri(url + query); + } + } + + /// + /// 获取或设置一个一个返回实例的委托,表示按字节下载限速的限速器的选项。 + /// + public Func? DownloadBytesRateLimiterOptionBuilder { get; set; } + + /// + /// 自动重试选项 + /// + public AutoRetryOptions AutoRetryOptions { get; } = new(); + + /// + /// 分卷ZIP文件的URL的URI对象的列表(内部,类型)。 + /// + private List? _zipFileUriList; + + /// + /// 一个的实例,用于DNS解析、IP地址测试和缓存。 + /// + private readonly HttpPartialDnsResolve _httpPartialDnsResolve = new(); + + /// + /// 获取一个用于分卷文件下载的类的新实例。 + /// + /// 的实例 + /// 当属性ZipFileUriList为空时引发此异常。 + public ZipFileDownload GetInstance() + => new(async (startBytes, endBytes) => + await VolumesFileHttpPartialDownloadStream.GetInstanceAsync(httpClient, + _zipFileUriList ?? throw new InvalidOperationException(), startBytes, endBytes, AutoRetryOptions, + ZipFileDownload.MediaType).ConfigureAwait(false), DownloadBytesRateLimiterOptionBuilder); +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/ZipFileDownload.cs b/src/Starward.Core.ZipStreamDownload/ZipFileDownload.cs new file mode 100644 index 000000000..2b4a432e8 --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/ZipFileDownload.cs @@ -0,0 +1,996 @@ +using System.Text; +using ICSharpCode.SharpZipLib.BZip2; +using ICSharpCode.SharpZipLib.Zip; +using ICSharpCode.SharpZipLib.Zip.Compression; +using ICSharpCode.SharpZipLib.Zip.Compression.Streams; +using Starward.Core.ZipStreamDownload.Exceptions; +using Starward.Core.ZipStreamDownload.Extensions; +using Starward.Core.ZipStreamDownload.Http; + +namespace Starward.Core.ZipStreamDownload; + +/// +/// 创建HTTP分段下载流的委托 +/// +/// 开始的字节索引(包含) +/// 结束的字节索引(不包含) +public delegate Task HttpPartialDownloadStreamBuilder(long? startBytes, long? endBytes); + +/// +/// ZIP文件下载类 +/// +/// 创建HTTP分段下载流的委托 +/// 一个返回实例的委托,表示按字节下载限速的限速器的选项 +public class ZipFileDownload( + HttpPartialDownloadStreamBuilder httpPartialDownloadStreamBuilder, + Func? downloadBytesRateLimiterOptionBuilder = null) +{ + /// + /// ZIP文件的MediaType + /// + internal const string MediaType = "application/zip"; + + /// + /// 的实例 + /// + private readonly StringCodec _stringCodec = ZipStrings.GetStringCodec(); + + /// + /// Skip the verification of the local header when reading an archive entry. Set this to attempt to read the + /// entries even if the headers should indicate that doing so would fail or produce an unexpected output. + /// + public bool SkipLocalEntryTestsOnLocate { get; set; } = false; + + /// + /// 进度报告参数 + /// + /// 已经下载的字节数 + /// 需要下载的字节总数 + public class ProgressChangedArgs(long downloadBytesCompleted, long? downloadBytesTotal = null) + { + /// + /// 已经下载的字节数 + /// + public long DownloadBytesCompleted { get; } = downloadBytesCompleted; + + /// + /// 需要下载的字节总数 + /// + public long? DownloadBytesTotal { get; } = downloadBytesTotal; + } + + /// + /// 从网络上指定的ZIP文件获取中央目录信息(异步)。 + /// + /// 用于写入中央目录信息的流 + /// 进度改变报告接口 + /// 取消令牌 + /// 一个任务。 + public async Task GetCentralDirectoryDataAsync(Stream writeStream, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + long position = 0; + long downloadCount = 0; + long? length = null; + + long locatedEndOfCentralDir = -1; + + IProgress? innerProgress = null; + + var httpPartialDownloadStream = await httpPartialDownloadStreamBuilder(null, 1024).ConfigureAwait(false); + await using var _ = httpPartialDownloadStream.ConfigureAwait(false); + + var rateLimitStream = GetRateLimitStream(httpPartialDownloadStream); + + var firstDownload = true; + var stream = new MemoryStream(1024); + await using var __ = stream.ConfigureAwait(false); + + if (progress != null) innerProgress = new Progress(bytes => + { + progress.Report(new ProgressChangedArgs(position + bytes, length)); + }); + + do + { + cancellationToken.ThrowIfCancellationRequested(); + await AddPositionAndDownloadAsync().ConfigureAwait(false); + if (downloadCount == 0) break; + locatedEndOfCentralDir = await ZipFormat.LocateBlockWithSignatureAsync(stream, + ZipConstants.EndOfCentralDirectorySignature, Math.Max(position - 3, 0), + ZipConstants.EndOfCentralRecordBaseSize, 0, reverse: true, cancellationToken) + .ConfigureAwait(false); + } while (position < 0xffff && locatedEndOfCentralDir < 0); + + if (locatedEndOfCentralDir < 0) + ZipFileTestFailedException.ThrowByReasonCentralDirectory("Cannot find central directory"); + + // Read end of central directory record + stream.SkipBytes( + sizeof(ushort) + //ushort thisDiskNumber + sizeof(ushort) + //ushort startCentralDirDisk + sizeof(ushort) + //ushort entriesForThisDisk + sizeof(ushort) //ushort entriesForWholeCentralDir + , reverse: true); + ulong centralDirSize = stream.ReadUint(reverse: true); + ulong offsetOfCentralDir = stream.ReadUint(reverse: true); + //ushort commentSize + + // Check if zip64 header information is required. + var zip64 = centralDirSize == 0xffffffff || offsetOfCentralDir == 0xffffffff; + + long locatedZip64EndOfCentralDirLocator = -1; + long offset64Reverse = -1; + if (zip64) + { + // #357 - always check for the existence of the Zip64 central directory. + // #403 - Take account of the fixed size of the locator when searching. + // Subtract from locatedEndOfCentralDir so that the endLocation is the location of EndOfCentralDirectorySignature, + // rather than the data following the signature. + locatedZip64EndOfCentralDirLocator = await ZipFormat.LocateBlockWithSignatureAsync(stream, + ZipConstants.Zip64CentralDirLocatorSignature, locatedEndOfCentralDir + 4, + ZipConstants.Zip64EndOfCentralDirectoryLocatorSize, 0, reverse: true, cancellationToken) + .ConfigureAwait(false); + if (locatedZip64EndOfCentralDirLocator < 0) + { + do + { + cancellationToken.ThrowIfCancellationRequested(); + await AddPositionAndDownloadAsync().ConfigureAwait(false); + if (downloadCount == 0) break; + locatedZip64EndOfCentralDirLocator = await ZipFormat.LocateBlockWithSignatureAsync(stream, + ZipConstants.Zip64CentralDirLocatorSignature, Math.Max(position - 3, 0), + ZipConstants.Zip64EndOfCentralDirectoryLocatorSize, 0, reverse: true, cancellationToken) + .ConfigureAwait(false); + } while (position < 0x1fffe && locatedZip64EndOfCentralDirLocator < 0); + + if (locatedZip64EndOfCentralDirLocator < 0) + ZipFileTestFailedException.ThrowByReasonCentralDirectory("Cannot find Zip64 locator"); + } + + // number of the disk with the start of the zip64 end of central directory 4 bytes + // relative offset of the zip64 end of central directory record 8 bytes + // total number of disks 4 bytes + stream.SkipBytes(sizeof(uint), reverse: true); //uint startDisk64 is not currently used + var offset64 = stream.ReadUlong(reverse: true); + if (offset64 > long.MaxValue) + ZipFileTestFailedException.ThrowByReasonCentralDirectory("The offset of Zip64 is too large"); + offset64Reverse = httpPartialDownloadStream.FileLength - (long)offset64; + //stream.SkipBytes(sizeof(uint), reverse: true); //uint totalDisks + + if (offset64Reverse > position) + await AddPositionAndDownloadAsync(offset64Reverse - position) + .ConfigureAwait(false); + + stream.Seek(offset64Reverse, SeekOrigin.Begin); + long sig64 = stream.ReadUint(reverse: true); + + if (sig64 != ZipConstants.Zip64CentralFileHeaderSignature) + ZipFileTestFailedException + .ThrowByReasonCentralDirectory($"Invalid Zip64 Central directory signature at {offset64:X}"); + + // NOTE: Record size = SizeOfFixedFields + SizeOfVariableData - 12. + stream.SkipBytes( + sizeof(ulong) + //ulong recordSize + sizeof(ushort) + //ushort versionMadeBy + sizeof(ushort) + //ushort versionToExtract + sizeof(uint) + //uint thisDisk + sizeof(uint) + //uint centralDirDisk + sizeof(ulong) + //ulong entriesForThisDisk + sizeof(ulong) //ulong entriesForWholeCentralDir + , reverse: true); + centralDirSize = stream.ReadUlong(reverse: true); + if (centralDirSize > long.MaxValue) + ZipFileTestFailedException.ThrowByReasonCentralDirectory("The size of Zip64 Central directory is too large"); + offsetOfCentralDir = stream.ReadUlong(reverse: true); + if (offsetOfCentralDir > long.MaxValue) + ZipFileTestFailedException.ThrowByReasonCentralDirectory("The size of Zip64 Central directory is too large"); + // NOTE: zip64 extensible data sector (variable size) is ignored. + } + + var centralDirectoryFileSize = httpPartialDownloadStream.FileLength - (long)offsetOfCentralDir; + if (centralDirectoryFileSize < 0) + ZipFileTestFailedException.ThrowByReasonCentralDirectory("The size of the central directory exceeds the compressed file size"); + + if (zip64) + { + stream.Seek(offset64Reverse - 48, SeekOrigin.Begin); + stream.WriteNumber(0UL, reverse: true); + stream.Seek(locatedZip64EndOfCentralDirLocator - 4, SeekOrigin.Begin); + stream.WriteNumber((ulong)centralDirectoryFileSize - (ulong)offset64Reverse, reverse: true); + } + else + { + stream.Seek(locatedEndOfCentralDir - 12, SeekOrigin.Begin); + stream.WriteNumber(0U, reverse: true); + } + + if (stream.Length > centralDirectoryFileSize) stream.SetLength(centralDirectoryFileSize); + stream.Seek(0, SeekOrigin.Begin); + var bufferLength = stream.Length; + writeStream.SetLength(centralDirectoryFileSize); + writeStream.Seek(0, SeekOrigin.End); + await stream.CopyToReverseAsync(writeStream, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + //writeStream.Seek(Math.Min(centralDirectoryFileSize, bufferLength), SeekOrigin.Current); + stream.Close(); + + if (centralDirectoryFileSize <= bufferLength) return; + + await httpPartialDownloadStream.ResetRangeAsync((long)offsetOfCentralDir, + httpPartialDownloadStream.FileLength - bufferLength, cancellationToken).ConfigureAwait(false); + position = bufferLength; + length = httpPartialDownloadStream.Length + bufferLength; + writeStream.Seek(0, SeekOrigin.Begin); + await rateLimitStream.CopyToAsync(writeStream, + progress: innerProgress, cancellationToken: cancellationToken).ConfigureAwait(false); + return; + + async Task AddPositionAndDownloadAsync(long count = 1024) + { + if (!firstDownload) + { + position += downloadCount; + if (position == httpPartialDownloadStream.FileLength) + { + downloadCount = 0; + return; + } + + var startBytes = httpPartialDownloadStream.StartBytes - count; + var endBytes = httpPartialDownloadStream.StartBytes; + if (startBytes < 0) startBytes = 0; + await httpPartialDownloadStream.ResetRangeAsync(startBytes, endBytes, cancellationToken) + .ConfigureAwait(false); + } + else firstDownload = false; + downloadCount = httpPartialDownloadStream.Length; + stream.SetLength(position + downloadCount); + stream.Seek(0, SeekOrigin.End); + await rateLimitStream.CopyToReverseAsync(stream, progress: innerProgress, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + } + + /// + /// 从网络上指定的ZIP文件获取中央目录信息(异步)。 + /// + /// 用于写入中央目录信息的流 + /// 取消令牌 + /// 一个任务。 + public Task GetCentralDirectoryDataAsync(Stream writeStream, CancellationToken cancellationToken) => + GetCentralDirectoryDataAsync(writeStream, null, cancellationToken); + + /// + /// 从网络上指定的ZIP文件获取中央目录信息。 + /// + /// 用于写入中央目录信息的流 + /// 进度改变报告接口 + public void GetCentralDirectoryData(Stream writeStream, + IProgress? progress = null) => + GetCentralDirectoryDataAsync(writeStream, progress).GetAwaiter().GetResult(); + + /// + /// 从网络上指定的ZIP文件获取中央目录信息(异步)。 + /// + /// 用于写入中央目录信息的文件的文件信息 + /// 进度改变报告接口 + public void GetCentralDirectoryData(FileInfo fileInfo, + IProgress? progress = null) + { + using var fileStream = fileInfo.Open(FileMode.Create, FileAccess.Write); + GetCentralDirectoryData(fileStream, progress); + } + + /// + /// 从网络上指定的ZIP文件获取中央目录信息(异步)。 + /// + /// 用于写入中央目录信息的文件的文件信息 + /// 进度改变报告接口 + /// 取消令牌 + /// 一个任务。 + public async Task GetCentralDirectoryDataAsync(FileInfo fileInfo, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + try + { + var fileStream = fileInfo.Open(FileMode.Create, FileAccess.Write); + await using var _ = fileStream.ConfigureAwait(false); + await GetCentralDirectoryDataAsync(fileStream, progress, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + if (fileInfo.Exists) fileInfo.Delete(); + throw; + } + } + + /// + /// 从网络上指定的ZIP文件获取中央目录信息(异步)。 + /// + /// 用于写入中央目录信息的文件的文件信息 + /// 取消令牌 + /// 一个任务。 + public Task GetCentralDirectoryDataAsync(FileInfo fileInfo, CancellationToken cancellationToken) => + GetCentralDirectoryDataAsync(fileInfo, null, cancellationToken); + + /// + /// 从网络上指定的ZIP文件获取中央目录信息(异步)。 + /// + /// 用于写入中央目录信息的文件的文件路径 + /// 进度改变报告接口 + public void GetCentralDirectoryData(string path, IProgress? progress = null) + => GetCentralDirectoryData(new FileInfo(path), progress); + + /// + /// 从网络上指定的ZIP文件获取中央目录信息(异步)。 + /// + /// 用于写入中央目录信息的文件的文件路径 + /// 进度改变报告接口 + /// 取消令牌 + /// 一个任务。 + public Task GetCentralDirectoryDataAsync(string path, IProgress? progress = null, + CancellationToken cancellationToken = default) + => GetCentralDirectoryDataAsync(new FileInfo(path), progress, cancellationToken); + + /// + /// 从网络上指定的ZIP文件获取中央目录信息(异步)。 + /// + /// 用于写入中央目录信息的文件的文件路径 + /// 取消令牌 + /// 一个任务。 + public Task GetCentralDirectoryDataAsync(string path, CancellationToken cancellationToken) + => GetCentralDirectoryDataAsync(path, null, cancellationToken); + + + /// + /// 获取通过实体创建的ZIP文件(异步)。 + /// + /// 的实例 + /// 要写入数据的流 + /// 进度报告对象 + /// 取消令牌 + /// 一个任务。 + public async Task GetEntryZipFileAsync(ZipEntry entry, Stream writeStream, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ThrowException.ThrowArgumentExceptionIfZipEntryNotIsFile(entry); + + writeStream.Seek(0, SeekOrigin.Begin); + var (compressedDataStart, nameData) = + await LocateEntryAsync(entry, writeStream, cancellationToken).ConfigureAwait(false); + var compressedDataEnd = compressedDataStart + entry.CompressedSize; + + if ((entry.Flags & (int)GeneralBitFlags.Descriptor) != 0) + { + if (entry.LocalHeaderRequiresZip64) compressedDataEnd += 24; + else compressedDataEnd += 16; + } + + var dataFileSize = compressedDataEnd - entry.Offset; + + if (writeStream.Length < dataFileSize) + { + var httpPartialDownloadStream = await httpPartialDownloadStreamBuilder(entry.Offset, compressedDataEnd) + .ConfigureAwait(false); + await using var _ = httpPartialDownloadStream.ConfigureAwait(false); + + var rateLimitStream = GetRateLimitStream(httpPartialDownloadStream); + + var startLength = httpPartialDownloadStream.Position = writeStream.Length; + + IProgress? innerProgress = null; + if (progress != null) innerProgress = new Progress(bytes => + { + ProgressReport(Math.Min(startLength + bytes - + ZipConstants.LocalHeaderBaseSize - compressedDataStart + entry.Offset, entry.CompressedSize), + entry.CompressedSize); + }); + + await rateLimitStream.CopyToAsync(writeStream, innerProgress, cancellationToken) + .ConfigureAwait(false); + } + + var newEntry = (ZipEntry)entry.Clone(); + newEntry.Offset = 0; + writeStream.Seek(dataFileSize, SeekOrigin.Begin); + var centralDirectorySize = WriteCentralDirectoryHeader(writeStream, newEntry, nameData); + ZipFormat.WriteEndOfCentralDirectory(writeStream, 1, + centralDirectorySize, dataFileSize, + newEntry.Comment == null ? null : _stringCodec.ZipArchiveCommentEncoding.GetBytes(newEntry.Comment)); + + return; + + void ProgressReport(long downloadBytesCompleted, long? downloadBytesCount = null) + { + progress.Report(new ProgressChangedArgs(downloadBytesCompleted, downloadBytesCount)); + } + } + + /// + /// 获取通过实体创建的ZIP文件(异步)。 + /// + /// 的实例 + /// 要写入数据的流 + /// 取消令牌 + /// 一个任务。 + public Task GetEntryZipFileAsync(ZipEntry entry, Stream writeStream, + CancellationToken cancellationToken) => + GetEntryZipFileAsync(entry, writeStream, null, cancellationToken); + + /// + /// 获取通过实体创建的ZIP文件。 + /// + /// 的实例 + /// 要写入数据的流 + /// 进度报告对象 + public void GetEntryZipFile(ZipEntry entry, Stream writeStream, + IProgress? progress = null) => + GetEntryZipFileAsync(entry, writeStream, progress).GetAwaiter().GetResult(); + + /// + /// 获取通过实体创建的ZIP文件(异步)。 + /// + /// 的实例 + /// 要写入的文件的文件信息 + /// 进度报告对象 + public void GetEntryZipFile(ZipEntry entry, FileInfo fileInfo, + IProgress? progress = null) + { + ThrowException.ThrowArgumentExceptionIfZipEntryNotIsFile(entry); + using var fileStream = fileInfo.Open(FileMode.OpenOrCreate, FileAccess.Write); + GetEntryZipFile(entry, fileStream, progress); + } + + /// + /// 获取通过实体创建的ZIP文件(异步)。 + /// + /// 的实例 + /// 要写入的文件的文件信息 + /// 进度报告对象 + /// 取消令牌 + /// 一个任务。 + public async Task GetEntryZipFileAsync(ZipEntry entry, FileInfo fileInfo, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ThrowException.ThrowArgumentExceptionIfZipEntryNotIsFile(entry); + try + { + var fileStream = fileInfo.Open(FileMode.OpenOrCreate, FileAccess.Write); + await using var _ = fileStream.ConfigureAwait(false); + await GetEntryZipFileAsync(entry, fileStream, progress, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + if (fileInfo is { Exists: true, Length: 0 }) fileInfo.Delete(); + throw; + } + } + + /// + /// 获取通过实体创建的ZIP文件(异步)。 + /// + /// 的实例 + /// 要写入的文件的文件信息 + /// 取消令牌 + /// 一个任务。 + public Task GetEntryZipFileAsync(ZipEntry entry, FileInfo fileInfo, CancellationToken cancellationToken) => + GetEntryZipFileAsync(entry, fileInfo, null, cancellationToken); + + /// + /// 获取通过实体创建的ZIP文件。 + /// + /// 的实例 + /// 要写入的文件的路径 + /// 进度报告对象 + public void GetEntryZipFile(ZipEntry entry, string path, + IProgress? progress = null) + { + ThrowException.ThrowArgumentExceptionIfZipEntryNotIsFile(entry); + GetEntryZipFile(entry, new FileInfo(path), progress); + } + + /// + /// 获取通过实体创建的ZIP文件(异步)。 + /// + /// 的实例 + /// 要写入的文件的路径 + /// 进度报告对象 + /// 取消令牌 + /// 一个任务。 + public Task GetEntryZipFileAsync(ZipEntry entry, string path, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ThrowException.ThrowArgumentExceptionIfZipEntryNotIsFile(entry); + return GetEntryZipFileAsync(entry, new FileInfo(path), progress, cancellationToken); + } + + /// + /// 获取通过实体创建的ZIP文件(异步)。 + /// + /// 的实例 + /// 要写入的文件的路径 + /// 取消令牌 + /// 一个任务。 + public Task GetEntryZipFileAsync(ZipEntry entry, string path, CancellationToken cancellationToken) => + GetEntryZipFileAsync(entry, path, null, cancellationToken); + + + /// + /// 获取读取解压文件的只读流(异步)。 + /// + /// 的实例,要获取的文件的ZIP实体(必须为文件类型实体)。 + /// 进度报告 + /// 取消令牌 + /// 一个任务,可以获取解压文件的只读流。 + public async Task GetInputStreamAsync(ZipEntry entry, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ThrowException.ThrowArgumentExceptionIfZipEntryNotIsFile(entry); + + var compressedDataStart = + (await LocateEntryAsync(entry, null, cancellationToken).ConfigureAwait(false)).Item1; + var compressedDataEnd = compressedDataStart + entry.CompressedSize; + + var httpPartialDownloadStream = await httpPartialDownloadStreamBuilder(compressedDataStart, compressedDataEnd) + .ConfigureAwait(false); + + var rateLimitStream = GetRateLimitStream(httpPartialDownloadStream); + + try + { + var progressReportReadStream = new ProgressReportReadStream(rateLimitStream, + new Progress(count => + { + progress?.Report(new ProgressChangedArgs(count, httpPartialDownloadStream.Length)); + })); + + switch (entry.CompressionMethod) + { + case CompressionMethod.Stored: + return progressReportReadStream; + case CompressionMethod.Deflated: + // No need to worry about ownership and closing as underlying stream close does nothing. + return new InflaterInputStream(progressReportReadStream, new Inflater(true)); + case CompressionMethod.BZip2: + return new BZip2InputStream(progressReportReadStream); + case CompressionMethod.Deflate64: + case CompressionMethod.LZMA: + case CompressionMethod.PPMd: + case CompressionMethod.WinZipAES: + default: + FeatureNotSupportedException.ThrowByReason("Unsupported compression method " + + entry.CompressionMethod); + return null!; + } + } + catch + { + await httpPartialDownloadStream.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + /// + /// 获取读取解压文件的只读流(异步)。 + /// + /// 的实例,要获取的文件的ZIP实体(必须为文件类型实体)。 + /// 取消令牌 + /// 一个任务,可以获取解压文件的只读流。 + public Task GetInputStreamAsync(ZipEntry entry, + CancellationToken cancellationToken) => + GetInputStreamAsync(entry, null, cancellationToken); + + /// + /// 获取读取解压文件的只读流。 + /// + /// 的实例,要获取的文件的ZIP实体(必须为文件类型实体)。 + /// 解压文件的只读流 + public Stream GetInputStream(ZipEntry entry) + => GetInputStreamAsync(entry).GetAwaiter().GetResult(); + + /// + /// Locate the data for a given entry. + /// + /// + /// The start offset of the data. + /// + /// + /// The stream ends prematurely + /// + /// + /// The local header signature is invalid, the entry and central header file name lengths are different + /// or the local and entry compression methods dont match + /// + private async Task<(long, byte[])> LocateEntryAsync(ZipEntry entry, Stream? writeStream = null, + CancellationToken cancellationToken = default) + { + Stream stream; + var httpPartialDownloadStream = await httpPartialDownloadStreamBuilder(entry.Offset, + entry.Offset + ZipConstants.LocalHeaderBaseSize).ConfigureAwait(false); + await using var _ = httpPartialDownloadStream.ConfigureAwait(false); + + var rateLimitStream = GetRateLimitStream(httpPartialDownloadStream); + + if (writeStream != null) + { + stream = new MemoryStream(); + stream.SetLength(httpPartialDownloadStream.Length); + await rateLimitStream.CopyToAsync(stream, cancellationToken).ConfigureAwait(false); + stream.Seek(0, SeekOrigin.Begin); + } + else stream = rateLimitStream; + + var dataOffset = await TestLocalHeaderAsync(stream, async (startBytes, endBytes) => + { + await httpPartialDownloadStream.ResetRangeAsync(startBytes, endBytes, cancellationToken) + .ConfigureAwait(false); + if (writeStream != null) + { + stream.SetLength(stream.Length + httpPartialDownloadStream.Length); + stream.Seek(-httpPartialDownloadStream.Length, SeekOrigin.End); + await rateLimitStream.CopyToAsync(stream, cancellationToken).ConfigureAwait(false); + stream.Seek(-httpPartialDownloadStream.Length, SeekOrigin.End); + } + }, entry, cancellationToken).ConfigureAwait(false); + + if (writeStream != null) + { + stream.Seek(0, SeekOrigin.Begin); + await stream.CopyToAsync(writeStream, cancellationToken).ConfigureAwait(false); + } + return dataOffset; + } + + /// + /// Test a local header against that provided from the central directory + /// + /// The stream from which data needs to be read + /// + /// The entry to test against + /// Cancellation token + /// The offset of the entries data in the file + private async Task<(long, byte[])> TestLocalHeaderAsync(Stream stream, + Func resetRangeCallbackAsync, ZipEntry entry, CancellationToken cancellationToken = default) + { + var signature = stream.ReadInt(); + + if (signature != ZipConstants.LocalHeaderSignature) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + $"Wrong local header signature at 0x{entry.Offset:x}, expected 0x{ZipConstants.LocalHeaderSignature:x8}, actual 0x{signature:x8}"); + + var extractVersion = (short)(stream.ReadUshort() & 0x00ff); + var localFlags = (GeneralBitFlags)stream.ReadUshort(); + var compressionMethod = (CompressionMethod)stream.ReadUshort(); + var fileTime = stream.ReadShort(); + var fileDate = stream.ReadShort(); + var crcValue = stream.ReadUint(); + long compressedSize = stream.ReadUint(); + long size = stream.ReadUint(); + int storedNameLength = stream.ReadUshort(); + int extraDataLength = stream.ReadUshort(); + var extraLength = storedNameLength + extraDataLength; + await resetRangeCallbackAsync(entry.Offset + ZipConstants.LocalHeaderBaseSize, + entry.Offset + ZipConstants.LocalHeaderBaseSize + extraLength).ConfigureAwait(false); + + var nameData = new byte[storedNameLength]; + await stream.ReadExactlyAsync(nameData, cancellationToken).ConfigureAwait(false); + + var extraData = new byte[extraDataLength]; + await stream.ReadExactlyAsync(extraData, cancellationToken).ConfigureAwait(false); + + var localExtraData = new ZipExtraData(extraData); + + // Extra data / zip64 checks + if (localExtraData.Find(headerID: 1)) + { + // 2010-03-04 Forum 10512: removed checks for version >= ZipConstants.VersionZip64 + // and size or compressedSize = MaxValue, due to rogue creators. + size = localExtraData.ReadLong(); + compressedSize = localExtraData.ReadLong(); + + if (localFlags.HasAny(GeneralBitFlags.Descriptor)) + { + // These may be valid if patched later + if (size != 0 && size != entry.Size) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Size invalid for descriptor"); + if (compressedSize != 0 && compressedSize != entry.CompressedSize) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Compressed size invalid for descriptor"); + } + } + else + { + // No zip64 extra data but entry requires it. + if (extractVersion >= ZipConstants.VersionZip64 && + ((uint)size == uint.MaxValue || (uint)compressedSize == uint.MaxValue)) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Required Zip64 extended information missing"); + } + + if (!SkipLocalEntryTestsOnLocate) + { + if (entry.IsFile) + { + if (!entry.IsCompressionMethodSupported()) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Compression method not supported"); + + if (extractVersion is > ZipConstants.VersionMadeBy or > 20 and < ZipConstants.VersionZip64) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + $"Version required to extract this entry not supported ({extractVersion})"); + + const GeneralBitFlags notSupportedFlags = GeneralBitFlags.Patched + | GeneralBitFlags.StrongEncryption + | GeneralBitFlags.EnhancedCompress + | GeneralBitFlags.HeaderMasked + | GeneralBitFlags.Encrypted; + if (localFlags.HasAny(notSupportedFlags)) + FeatureNotSupportedException.ThrowByReason( + $"The library does not support the zip features required to extract this entry ({localFlags & notSupportedFlags:F})"); + } + + if (extractVersion <= 63 && // Ignore later versions as we dont know about them.. + extractVersion != 10 && + extractVersion != 11 && + extractVersion != 20 && + extractVersion != 21 && + extractVersion != 25 && + extractVersion != 27 && + extractVersion != 45 && + extractVersion != 46 && + extractVersion != 50 && + extractVersion != 51 && + extractVersion != 52 && + extractVersion != 61 && + extractVersion != 62 && + extractVersion != 63 + ) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + $"Version required to extract this entry is invalid ({extractVersion})"); + + var localEncoding = _stringCodec.ZipInputEncoding(localFlags); + + // Local entry flags dont have reserved bit set on. + if (localFlags.HasAny(GeneralBitFlags.ReservedPKware4 | GeneralBitFlags.ReservedPkware14 | + GeneralBitFlags.ReservedPkware15)) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Reserved bit flags cannot be set."); + + // Encryption requires extract version >= 20 + if (localFlags.HasAny(GeneralBitFlags.Encrypted) && extractVersion < 20) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + $"Version required to extract this entry is too low for encryption ({extractVersion})"); + + // Strong encryption requires encryption flag to be set and extract version >= 50. + if (localFlags.HasAny(GeneralBitFlags.StrongEncryption)) + { + if (!localFlags.HasAny(GeneralBitFlags.Encrypted)) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Strong encryption flag set but encryption flag is not set"); + + if (extractVersion < 50) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + $"Version required to extract this entry is too low for encryption ({extractVersion})"); + } + + // Patched entries require extract version >= 27 + if (localFlags.HasAny(GeneralBitFlags.Patched) && extractVersion < 27) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + $"Patched data requires higher version than ({extractVersion})"); + + // Central header flags match local entry flags. + if ((int)localFlags != entry.Flags) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + $"Central header/local header flags mismatch ({(GeneralBitFlags)entry.Flags:F} vs {localFlags:F})"); + + // Central header compression method matches local entry + var compressionMethodForHeader = + entry.AESKeySize > 0 ? CompressionMethod.WinZipAES : entry.CompressionMethod; + if (compressionMethodForHeader != compressionMethod) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + $"Central header/local header compression method mismatch ({compressionMethodForHeader:G} vs {compressionMethod:G})"); + + //if (entry.Version != extractVersion) + // throw new ZipException("Extract version mismatch"); + + // Strong encryption and extract version match + if (localFlags.HasAny(GeneralBitFlags.StrongEncryption)) + { + if (extractVersion < 62) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Strong encryption flag set but version not high enough"); + } + + if (localFlags.HasAny(GeneralBitFlags.HeaderMasked)) + { + if (fileTime != 0 || fileDate != 0) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Header masked set but date/time values non-zero"); + } + + if (!localFlags.HasAny(GeneralBitFlags.Descriptor)) + { + if (crcValue != (uint)entry.Crc) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Central header/local header crc mismatch"); + } + + // Crc valid for empty entry. + // This will also apply to streamed entries where size isn't known and the header cant be patched + if (size == 0 && compressedSize == 0) + { + if (crcValue != 0) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Invalid CRC for empty entry"); + } + + // TODO: make test more correct... can't compare lengths as was done originally as this can fail for MBCS strings + // Assuming a code page at this point is not valid? Best is to store the name length in the ZipEntry probably + if (entry.Name.Length > storedNameLength) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "File name length mismatch"); + + // Name data has already been read convert it and compare. + var localName = localEncoding.GetString(nameData); + + // Central directory and local entry name match + if (localName != entry.Name) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Central header and local header file name mismatch"); + + // Directories have zero actual size but can have compressed size + if (entry.IsDirectory) + { + if (size > 0) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Directory cannot have size"); + + // There may be other cases where the compressed size can be greater than this? + // If so until details are known we will be strict. + if (entry.IsCrypted) + FeatureNotSupportedException.ThrowByReason("The library does not support the directory crypted"); + if (compressedSize > 2) + // When not compressed the directory size can validly be 2 bytes + // if the true size wasn't known when data was originally being written. + // NOTE: Versions of the library 0.85.4 and earlier always added 2 bytes + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Directory compressed size invalid"); + } + + if (!ZipNameTransform.IsValidName(localName, true)) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Name is invalid"); + } + + // Tests that apply to both data and header. + + // Size can be verified only if it is known in the local header. + // it will always be known in the central header. + if (!localFlags.HasAny(GeneralBitFlags.Descriptor) || + ((size > 0 || compressedSize > 0) && entry.Size > 0)) + { + if (size != 0 && size != entry.Size) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + $"Size mismatch between central header ({entry.Size}) and local header ({size})"); + + if (compressedSize != 0 + && compressedSize != entry.CompressedSize && compressedSize != 0xFFFFFFFF && compressedSize != -1) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + $"Compressed size mismatch between central header({entry.CompressedSize}) and local header({compressedSize})"); + } + return (entry.Offset + ZipConstants.LocalHeaderBaseSize + extraLength, nameData); + } + + /// + /// 写入中央目录头 + /// + /// 要写入数据的流。 + /// 的实例。 + /// 名称数据 + /// 为防止重新编码导致名称数据不一致,此处引用未经解码的名称数据。 + /// 写入的总字节数。 + private static int WriteCentralDirectoryHeader(Stream stream, ZipEntry entry, byte[] nameData) + { + if (entry.CompressedSize < 0) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Attempt to write central directory entry with unknown csize"); + if (entry.Size < 0) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Attempt to write central directory entry with unknown size"); + if (entry.Crc < 0) + ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Attempt to write central directory entry with unknown crc"); + + // Write the central file header + stream.WriteNumber((uint)ZipConstants.CentralHeaderSignature); + // Version made by + stream.WriteNumber((ushort)((entry.HostSystem << 8) | entry.VersionMadeBy)); + // Version required to extract + stream.WriteNumber((ushort)entry.Version); + stream.WriteNumber((ushort)entry.Flags); + unchecked + { + stream.WriteNumber((ushort) + (entry.AESKeySize > 0 ? CompressionMethod.WinZipAES : entry.CompressionMethod)); + stream.WriteNumber((uint)entry.DosTime); + stream.WriteNumber((uint)entry.Crc); + } + + var useExtraCompressedSize = false; //Do we want to store the compressed size in the extra data? + if (entry.IsZip64Forced() || entry.CompressedSize >= 0xffffffff) + { + useExtraCompressedSize = true; + stream.WriteNumber(-1); + } + else stream.WriteNumber((uint)(entry.CompressedSize & 0xffffffff)); + + var useExtraUncompressedSize = false; //Do we want to store the uncompressed size in the extra data? + if (entry.IsZip64Forced() || entry.Size >= 0xffffffff) + { + useExtraUncompressedSize = true; + stream.WriteNumber(-1); + } + else stream.WriteNumber((uint)entry.Size); + + //var entryEncoding = _stringCodec.ZipInputEncoding(entry.Flags); + //var name = entryEncoding.GetBytes(entry.Name); + + if (nameData.Length > 0xFFFF) ZipFileTestFailedException.ThrowByZipEntryNameAndReason(entry.Name, + "Entry name is too long."); + stream.WriteNumber((ushort)nameData.Length); + + // Central header extra data is different to local header version so regenerate. + var ed = new ZipExtraData(entry.ExtraData); + if (entry.CentralHeaderRequiresZip64) + { + ed.StartNewEntry(); + if (useExtraUncompressedSize) ed.AddLeLong(entry.Size); + if (useExtraCompressedSize) ed.AddLeLong(entry.CompressedSize); + if (entry.Offset >= 0xffffffff) ed.AddLeLong(entry.Offset); + // Number of disk on which this file starts isnt supported and is never written here. + ed.AddNewEntry(1); + } + else ed.Delete(1); // Should have already be done when local header was added. + + var centralExtraData = ed.GetEntryData(); + + stream.WriteNumber((ushort)centralExtraData.Length); + stream.WriteNumber((ushort)(entry.Comment?.Length ?? 0)); + + stream.WriteNumber((short)0); // disk number + stream.WriteNumber((short)0); // internal file attributes + + // External file attributes... + if (entry.ExternalFileAttributes != -1) + stream.WriteNumber((uint)entry.ExternalFileAttributes); + else + { + stream.WriteNumber(entry.IsDirectory ? 16U : 0U); + } + + if (entry.Offset >= 0xffffffff) stream.WriteNumber(0xffffffffU); + else stream.WriteNumber((uint)(int)entry.Offset); + + if (nameData.Length > 0) + stream.WriteLittleEndianBytes(nameData); + if (centralExtraData.Length > 0) + stream.WriteLittleEndianBytes(centralExtraData); + + var rawComment = entry.Comment != null ? Encoding.ASCII.GetBytes(entry.Comment) : []; + if (rawComment.Length > 0) stream.WriteLittleEndianBytes(rawComment); + + return ZipConstants.CentralHeaderBaseSize + nameData.Length + centralExtraData.Length + rawComment.Length; + } + + private Stream GetRateLimitStream(Stream stream) + => downloadBytesRateLimiterOptionBuilder == null ? + stream : new RateLimitReadStream(stream, downloadBytesRateLimiterOptionBuilder); +} \ No newline at end of file diff --git a/src/Starward.Core.ZipStreamDownload/ZipFormat.cs b/src/Starward.Core.ZipStreamDownload/ZipFormat.cs new file mode 100644 index 000000000..ee1ef413d --- /dev/null +++ b/src/Starward.Core.ZipStreamDownload/ZipFormat.cs @@ -0,0 +1,181 @@ +using ICSharpCode.SharpZipLib.Zip; +using Starward.Core.ZipStreamDownload.Exceptions; +using Starward.Core.ZipStreamDownload.Extensions; + +namespace Starward.Core.ZipStreamDownload; + +/// +/// ZIP格式帮助类 +/// +internal static class ZipFormat +{ + /// + /// Reverse locates a block with the desired . + /// + /// + /// The signature to find. + /// Location, marking the end of block. + /// Minimum size of the block. + /// The maximum variable data. + /// + /// + private static Task _LocateBlockWithSignatureAsync(Stream stream, int signature, + long endLocation, int minimumBlockSize, int maximumVariableData, + CancellationToken cancellationToken = default) + { + var pos = endLocation - minimumBlockSize; + if (pos < 0) return Task.FromResult(-1); + var giveUpMarker = Math.Max(pos - maximumVariableData, 0); + // TODO: This loop could be optimized for speed. + do + { + cancellationToken.ThrowIfCancellationRequested(); + if (pos < giveUpMarker) return Task.FromResult(-1); + stream.Seek(pos--, SeekOrigin.Begin); + } while (stream.ReadInt() != signature); + return Task.FromResult(stream.Position); + } + + /// + /// Reverse locates a block with the desired (Async). + /// + /// + /// The signature to find. + /// Location, marking the end of block. + /// Minimum size of the block. + /// The maximum variable data. + /// Cancellation token + /// + private static Task _LocateBlockWithSignatureReverseAsync(Stream stream, int signature, + long startLocation, int minimumBlockSize, int maximumVariableData, + CancellationToken cancellationToken = default) + { + var pos = startLocation + minimumBlockSize; + if (pos > stream.Length) return Task.FromResult(-1); + var giveUpMarker = Math.Min(pos + maximumVariableData, stream.Length); + // TODO: This loop could be optimized for speed. + do + { + cancellationToken.ThrowIfCancellationRequested(); + if (pos > giveUpMarker) return Task.FromResult(-1); + stream.Seek(pos++, SeekOrigin.Begin); + } while (stream.ReadInt(reverse: true) != signature); + return Task.FromResult(stream.Position); + } + + /// + /// Locates a block with the desired . + /// + /// + /// The signature to find. + /// Location, marking the end of block. + /// Minimum size of the block. + /// The maximum variable data. + /// Reverse lookup + /// Returns the offset of the first byte after the signature; -1 if not found + public static long LocateBlockWithSignature(Stream stream, int signature, + long startLocation, int minimumBlockSize, int maximumVariableData, bool reverse = false) + => reverse + ? _LocateBlockWithSignatureReverseAsync(stream, signature, startLocation, + minimumBlockSize, maximumVariableData).GetAwaiter().GetResult() + : _LocateBlockWithSignatureAsync(stream, signature, startLocation, + minimumBlockSize, maximumVariableData).GetAwaiter().GetResult(); + + /// + /// Locates a block with the desired (Async). + /// + /// + /// The signature to find. + /// Location, marking the end of block. + /// Minimum size of the block. + /// The maximum variable data. + /// Reverse lookup + /// Cancellation token + /// Returns the offset of the first byte after the signature; -1 if not found + public static Task LocateBlockWithSignatureAsync(Stream stream, int signature, + long startLocation, int minimumBlockSize, int maximumVariableData, bool reverse = false, + CancellationToken cancellationToken = default) + => reverse + ? _LocateBlockWithSignatureReverseAsync(stream, signature, startLocation, minimumBlockSize, + maximumVariableData, cancellationToken) + : _LocateBlockWithSignatureAsync(stream, signature, startLocation, minimumBlockSize, maximumVariableData, + cancellationToken); + + /// + /// Write the required records to end the central directory. + /// + /// + /// The number of entries in the directory. + /// The size of the entries in the directory. + /// The start of the central directory. + /// The archive comment. (This can be null). + internal static void WriteEndOfCentralDirectory(Stream stream, + long noOfEntries, long sizeEntries, long start, byte[]? comment) + { + if (noOfEntries >= 0xffff || + start >= 0xffffffff || + sizeEntries >= 0xffffffff) + WriteZip64EndOfCentralDirectory(stream, noOfEntries, sizeEntries, start); + stream.WriteNumber(ZipConstants.EndOfCentralDirectorySignature); + // TODO: ZipFile Multi disk handling not done + stream.WriteNumber((ushort)0); // number of this disk + stream.WriteNumber((ushort)0); // no of disk with start of central dir + // Number of entries + if (noOfEntries >= 0xffff) + { + stream.WriteNumber((ushort)0xffff); // Zip64 marker + stream.WriteNumber((ushort)0xffff); + } + else + { + stream.WriteNumber((ushort)noOfEntries); // entries in central dir for this disk + stream.WriteNumber((ushort)noOfEntries); // total entries in central directory + } + + // Size of the central directory + if (sizeEntries >= 0xffffffff) stream.WriteNumber(0xffffffffU); // Zip64 marker + else stream.WriteNumber((uint)sizeEntries); + + // offset of start of central directory + if (start >= 0xffffffff) stream.WriteNumber(0xffffffffU); // Zip64 marker + else stream.WriteNumber((uint)start); + var commentLength = comment?.Length ?? 0; + if (commentLength > 0xffff) ZipFileTestFailedException + .ThrowByReasonCentralDirectory($"Comment length ({commentLength}) is larger than 64K"); + stream.WriteNumber((ushort)commentLength); + if (commentLength > 0) stream.WriteLittleEndianBytes(comment); + } + + /// + /// Write Zip64 end of central directory records (File header and locator). + /// + /// + /// The number of entries in the central directory. + /// The size of entries in the central directory. + /// The offset of the central directory. + private static void WriteZip64EndOfCentralDirectory(Stream stream, + long noOfEntries, long sizeEntries, long centralDirOffset) + { + var centralSignatureOffset = centralDirOffset + sizeEntries; + stream.WriteNumber((uint)ZipConstants.Zip64CentralFileHeaderSignature); + stream.WriteNumber(44UL); // Size of this record (total size of remaining fields in header or full size - 12) + stream.WriteNumber((ushort)ZipConstants.VersionMadeBy); // Version made by + stream.WriteNumber((ushort)ZipConstants.VersionZip64); // Version to extract + stream.WriteNumber(0U); // Number of this disk + stream.WriteNumber(0U); // number of the disk with the start of the central directory + stream.WriteNumber(noOfEntries); // No of entries on this disk + stream.WriteNumber(noOfEntries); // Total No of entries in central directory + stream.WriteNumber(sizeEntries); // Size of the central directory + stream.WriteNumber(centralDirOffset); // offset of start of central directory + + // zip64 extensible data sector not catered for here (variable size) + // Write the Zip64 end of central directory locator + stream.WriteNumber((uint)ZipConstants.Zip64CentralDirLocatorSignature); + // no of the disk with the start of the zip64 end of central directory + stream.WriteNumber(0U); + // relative offset of the zip64 end of central directory record + stream.WriteNumber((ulong)centralSignatureOffset); + // total number of disks + stream.WriteNumber(1U); + } +} \ No newline at end of file diff --git a/src/Starward.Language/Lang.Designer.cs b/src/Starward.Language/Lang.Designer.cs index 165ff2bed..6b257b594 100644 --- a/src/Starward.Language/Lang.Designer.cs +++ b/src/Starward.Language/Lang.Designer.cs @@ -565,6 +565,15 @@ public static string DownloadGamePage_0FilesVerifyFailed { } } + /// + /// 查找类似 Cancelling 的本地化字符串。 + /// + public static string DownloadGamePage_Cancelling { + get { + return ResourceManager.GetString("DownloadGamePage_Cancelling", resourceCulture); + } + } + /// /// 查找类似 Close 的本地化字符串。 /// @@ -710,6 +719,15 @@ public static string DownloadGamePage_Paused { } } + /// + /// 查找类似 Pausing 的本地化字符串。 + /// + public static string DownloadGamePage_Pausing { + get { + return ResourceManager.GetString("DownloadGamePage_Pausing", resourceCulture); + } + } + /// /// 查找类似 Please start as administrator 的本地化字符串。 /// @@ -917,6 +935,42 @@ public static string DownloadSettingPage_DefaultGameInstallationPathDescription } } + /// + /// 查找类似 Download Mode (Experimental) 的本地化字符串。 + /// + public static string DownloadSettingPage_DownloadMode { + get { + return ResourceManager.GetString("DownloadSettingPage_DownloadMode", resourceCulture); + } + } + + /// + /// 查找类似 Streaming download mode takes up less disk space and provides faster download and decompression speeds. (Honkai Impact 3rd only supports traditional download mode) 的本地化字符串。 + /// + public static string DownloadSettingPage_DownloadModeDescription { + get { + return ResourceManager.GetString("DownloadSettingPage_DownloadModeDescription", resourceCulture); + } + } + + /// + /// 查找类似 Streaming download mode (decompress while downloading) 的本地化字符串。 + /// + public static string DownloadSettingPage_DownloadModeOptionStream { + get { + return ResourceManager.GetString("DownloadSettingPage_DownloadModeOptionStream", resourceCulture); + } + } + + /// + /// 查找类似 Traditional download mode (download first and then decompress) 的本地化字符串。 + /// + public static string DownloadSettingPage_DownloadModeOptionTraditional { + get { + return ResourceManager.GetString("DownloadSettingPage_DownloadModeOptionTraditional", resourceCulture); + } + } + /// /// 查找类似 Speed Limit 的本地化字符串。 /// diff --git a/src/Starward.Language/Lang.de-DE.resx b/src/Starward.Language/Lang.de-DE.resx index ee42d726d..6926422d7 100644 --- a/src/Starward.Language/Lang.de-DE.resx +++ b/src/Starward.Language/Lang.de-DE.resx @@ -1574,10 +1574,22 @@ Wenn Sie das Programm während der Dekomprimierung schließen, wird die Spieldat Visionary Mode - + Brilliant Blessing - + Removable storage device not connected. + + Download-Modus (experimentell) + + + Der Streaming-Download-Modus benötigt weniger Speicherplatz und bietet schnellere Download- und Dekomprimierungsgeschwindigkeiten. (Honkai Impact 3rd unterstützt nur den traditionellen Download-Modus) + + + Traditioneller Download-Modus (zuerst herunterladen und dann dekomprimieren) + + + Streaming-Download-Modus (während des Downloads dekomprimieren) + \ No newline at end of file diff --git a/src/Starward.Language/Lang.es-ES.resx b/src/Starward.Language/Lang.es-ES.resx index 4149fb93c..19e9b6eb5 100644 --- a/src/Starward.Language/Lang.es-ES.resx +++ b/src/Starward.Language/Lang.es-ES.resx @@ -1576,10 +1576,22 @@ Do you accept the risk and continue to use it? Visionary Mode - + Brilliant Blessing - + Removable storage device not connected. + + Modo de descarga (experimental) + + + Der Streaming-Download-Modus benötigt weniger Speicherplatz und bietet schnellere Download- und Dekomprimierungsgeschwindigkeiten. (Honkai Impact 3rd unterstützt nur den traditionellen Download-Modus) + + + Traditioneller Download-Modus (zuerst herunterladen und dann dekomprimieren) + + + Modo de descarga en streaming (descomprimir durante la descarga) + \ No newline at end of file diff --git a/src/Starward.Language/Lang.it-IT.resx b/src/Starward.Language/Lang.it-IT.resx index ff69174b4..d8de0c35a 100644 --- a/src/Starward.Language/Lang.it-IT.resx +++ b/src/Starward.Language/Lang.it-IT.resx @@ -1576,10 +1576,22 @@ Do you accept the risk and continue to use it? Visionary Mode - + Brilliant Blessing - + Removable storage device not connected. + + Modo de descarga (experimental) + + + Der Streaming-Download-Modus benötigt weniger Speicherplatz und bietet schnellere Download- und Dekomprimierungsgeschwindigkeiten. (Honkai Impact 3rd unterstützt nur den traditionellen Download-Modus) + + + Traditioneller Download-Modus (zuerst herunterladen und dann dekomprimieren) + + + Modalità download in streaming (decomprimi durante il download) + \ No newline at end of file diff --git a/src/Starward.Language/Lang.ja-JP.resx b/src/Starward.Language/Lang.ja-JP.resx index 5b6c396cd..354544e3d 100644 --- a/src/Starward.Language/Lang.ja-JP.resx +++ b/src/Starward.Language/Lang.ja-JP.resx @@ -1581,4 +1581,16 @@ リムーバブルストレージデバイスが接続されていません。 + + ダウンロード モード (実験的) + + + ストリーミング ダウンロード モードでは、使用するディスク容量が少なくなり、ダウンロードと解凍の速度が速くなります。 (崩壊3rdは従来のダウンロードモードのみをサポートします) + + + 従来のダウンロード モード (最初にダウンロードしてから解凍) + + + ストリーミングダウンロードモード(ダウンロード中に解凍) + \ No newline at end of file diff --git a/src/Starward.Language/Lang.ko-KR.resx b/src/Starward.Language/Lang.ko-KR.resx index e5f60db84..a60c5cc59 100644 --- a/src/Starward.Language/Lang.ko-KR.resx +++ b/src/Starward.Language/Lang.ko-KR.resx @@ -1576,10 +1576,22 @@ Visionary Mode - + Brilliant Blessing - + Removable storage device not connected. + + 다운로드 모드(실험적) + + + 스트리밍 다운로드 모드는 디스크 공간을 덜 차지하고 더 빠른 다운로드 및 압축 해제 속도를 제공합니다. (Honkai Impact 3rd는 기존 다운로드 모드만 지원합니다) + + + 기존 다운로드 모드(먼저 다운로드한 후 압축 해제) + + + 스트리밍 다운로드 모드(다운로드하는 동안 압축 해제) + \ No newline at end of file diff --git a/src/Starward.Language/Lang.resx b/src/Starward.Language/Lang.resx index 616fd3234..d98957c75 100644 --- a/src/Starward.Language/Lang.resx +++ b/src/Starward.Language/Lang.resx @@ -1479,6 +1479,12 @@ Do you accept the risk and continue to use it? Paused + + Pausing + + + Cancelling + Download task completed @@ -1518,6 +1524,18 @@ Do you accept the risk and continue to use it? Hard Link + + Download Mode (Experimental) + + + Streaming download mode takes up less disk space and provides faster download and decompression speeds. (Honkai Impact 3rd only supports traditional download mode) + + + Traditional download mode (download first and then decompress) + + + Streaming download mode (decompress while downloading) + Retrieve All Data diff --git a/src/Starward.Language/Lang.ru-RU.resx b/src/Starward.Language/Lang.ru-RU.resx index a17d018ee..42bcf2fab 100644 --- a/src/Starward.Language/Lang.ru-RU.resx +++ b/src/Starward.Language/Lang.ru-RU.resx @@ -1583,4 +1583,16 @@ Съемное устройство хранения не подключено. + + Режим загрузки (экспериментальный) + + + Режим потоковой загрузки занимает меньше места на диске и обеспечивает более высокую скорость загрузки и распаковки. (Honkai Impact 3rd поддерживает только традиционный режим загрузки) + + + Традиционный режим загрузки (сначала загрузка, а затем распаковка) + + + Режим потоковой загрузки (распаковка во время загрузки) + \ No newline at end of file diff --git a/src/Starward.Language/Lang.th-TH.resx b/src/Starward.Language/Lang.th-TH.resx index a7073cf24..3d64c3b22 100644 --- a/src/Starward.Language/Lang.th-TH.resx +++ b/src/Starward.Language/Lang.th-TH.resx @@ -1581,4 +1581,16 @@ Removable storage device not connected. + + โหมดดาวน์โหลด (ทดลอง) + + + โหมดการดาวน์โหลดแบบสตรีมมิ่งใช้พื้นที่ดิสก์น้อยลงและให้ความเร็วในการดาวน์โหลดและคลายการบีบอัดที่เร็วขึ้น (Honkai Impact 3rd รองรับเฉพาะโหมดดาวน์โหลดแบบดั้งเดิมเท่านั้น) + + + โหมดการดาวน์โหลดแบบดั้งเดิม (ดาวน์โหลดก่อนแล้วจึงขยายขนาด) + + + โหมดดาวน์โหลดสตรีมมิ่ง (ขยายขนาดขณะดาวน์โหลด) + \ No newline at end of file diff --git a/src/Starward.Language/Lang.vi-VN.resx b/src/Starward.Language/Lang.vi-VN.resx index bc2b8c483..4081e863e 100644 --- a/src/Starward.Language/Lang.vi-VN.resx +++ b/src/Starward.Language/Lang.vi-VN.resx @@ -1582,4 +1582,16 @@ Bạn có chấp nhận rủi ro và tiếp tục? Removable storage device not connected. + + Chế độ tải xuống (Thử nghiệm) + + + Chế độ tải xuống trực tuyến chiếm ít dung lượng đĩa hơn và cung cấp tốc độ tải xuống và giải nén nhanh hơn. (Honkai Impact 3rd chỉ hỗ trợ chế độ tải xuống truyền thống) + + + Chế độ tải truyền thống (tải trước rồi giải nén) + + + Chế độ tải trực tuyến (giải nén trong khi tải xuống) + \ No newline at end of file diff --git a/src/Starward.Language/Lang.zh-CN.resx b/src/Starward.Language/Lang.zh-CN.resx index 967f4ab4b..1601c28cb 100644 --- a/src/Starward.Language/Lang.zh-CN.resx +++ b/src/Starward.Language/Lang.zh-CN.resx @@ -1479,6 +1479,12 @@ 已暂停 + + 暂停中 + + + 取消中 + 下载任务已完成 @@ -1518,6 +1524,18 @@ 硬链接 + + 下载模式(实验性功能) + + + 流式下载模式具有更少的磁盘空间占用和更快的下载和解压速度。(崩坏三只支持传统下载模式) + + + 传统下载模式(先下载后解压) + + + 流式下载模式(边下载边解压) + 重新获取所有数据 diff --git a/src/Starward.Language/Lang.zh-TW.resx b/src/Starward.Language/Lang.zh-TW.resx index 06f4beb8d..5174a29c2 100644 --- a/src/Starward.Language/Lang.zh-TW.resx +++ b/src/Starward.Language/Lang.zh-TW.resx @@ -1479,6 +1479,12 @@ 已暫停 + + 暫停中 + + + 取消中 + 下載任務已完成 @@ -1582,4 +1588,16 @@ Removable storage device not connected. + + 下載模式(實驗) + + + 串流下載模式佔用更少的磁碟空間,並提供更快的下載和解壓速度。 (崩壞3rd僅支援傳統下載模式) + + + 傳統下載方式(先下載後解壓縮) + + + 串流下載模式(邊下載邊解壓縮) + \ No newline at end of file diff --git a/src/Starward/AppConfig.cs b/src/Starward/AppConfig.cs index 09ee0604b..5f18c6cc2 100644 --- a/src/Starward/AppConfig.cs +++ b/src/Starward/AppConfig.cs @@ -231,7 +231,7 @@ private static void BuildServiceProvider() { //See: https://learn.microsoft.com/zh-cn/dotnet/fundamentals/runtime-libraries/system-net-http-httpclienthandler //See: https://learn.microsoft.com/zh-cn/dotnet/api/system.net.http.socketshttphandler?view=net-8.0 - var client = new HttpClient(new SocketsHttpHandler { AutomaticDecompression = DecompressionMethods.All }) { DefaultRequestVersion = HttpVersion.Version20 }; + var client = new HttpClient(new SocketsHttpHandler { AutomaticDecompression = DecompressionMethods.All, MaxConnectionsPerServer = 5 }) { DefaultRequestVersion = HttpVersion.Version20 }; client.DefaultRequestHeaders.Add("User-Agent", $"Starward/{AppVersion}"); return client; }); @@ -514,6 +514,13 @@ public static int SpeedLimitKBPerSecond } + public static DownloadModeOption DownloadMode + { + get => GetValue(DownloadModeOption.TraditionalMode); + set => SetValue(value); + } + + #endregion diff --git a/src/Starward/Controls/InstallGameController.xaml b/src/Starward/Controls/InstallGameController.xaml index 5b17b4313..fa85e26fa 100644 --- a/src/Starward/Controls/InstallGameController.xaml +++ b/src/Starward/Controls/InstallGameController.xaml @@ -173,6 +173,7 @@ HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Command="{Binding CancelCommand}" + IsEnabled="{Binding IsCancelButtonEnabled}" Style="{ThemeResource DateTimePickerFlyoutButtonStyle}"> @@ -71,6 +72,31 @@