Public/Start-Download.ps1

<#
.SYNOPSIS
    PowerShell module to download files with support for multiple threads for improved speed.
.DESCRIPTION
    Downloads a file from a specified URL. It supports multi-threaded downloads, progress reporting, hash verification, and automatic retries.

    To see all available parameters:
    Get-Help Start-Download -Full

    To see just the examples:
    Get-Help Start-Download -Examples
.PARAMETER Url
    The URL of the file to download.
    Can be directly specified or piped from another command.
    Either a single URL or an array of URLs can be provided.
.PARAMETER Destination
    The local path where the file should be saved. Can be either a file path or directory.
    If a directory is specified, the filename will be extracted from the URL or server response.
.PARAMETER TempPath
    Directory to store temporary segment files. Defaults to system temp directory.
.PARAMETER NoProgress
    Suppresses the download progress bar.
.PARAMETER Quiet
    Suppresses all output except errors.
.PARAMETER Force
    Overwrites the destination file if it already exists.
.PARAMETER Threads
    Number of concurrent download threads. Higher numbers may improve speed but use more memory.
    Defaults to 1. Recommended range: 1-16.
.PARAMETER MaxRetry
    Maximum number of retry attempts if download fails. Defaults to 3.
.PARAMETER Timeout
    Timeout for the HTTP request in seconds. Defaults to 30.
.PARAMETER ExpectedHash
    Expected file hash. If specified, verifies the downloaded file's hash matches.
    Will retry the download if verification fails.

    Warning: if processing multiple URLs through the pipeline operator, hash verification will be disabled after the first URL.
.PARAMETER HashType
    Type of hash to verify. Valid values: MD5, SHA1, SHA256, SHA384, SHA512, CRC32.
    Defaults to MD5 if unspecified.
.PARAMETER UserAgent
    User agent string for the HTTP request. Change if experiencing server restrictions.
    Available presets:
    - 'Chrome' (default): Latest Chrome browser
    - 'Firefox': Latest Firefox browser
    - 'Edge': Latest Edge browser
    - 'Safari': Latest Safari browser
    - 'Opera': Latest Opera browser
    - 'Simple': Simple Mozilla string
    - 'Wget': Wget-like user agent
    - 'Curl': Curl-like user agent
    - 'PS': PowerShell user agent
    - 'None': Empty string (no user agent)
    - Or provide your own custom user agent string
.EXAMPLE
    Start-Download -Url "https://example.com/file.zip"

.EXAMPLE
    Start-Download -Url "https://example.com/file.zip" -Destination "C:\Downloads" -Threads 8

.EXAMPLE
    Start-Download -Url "https://example.com/file.zip" -ExpectedHash "1234ABCD..." -HashType SHA256

.EXAMPLE
    Start-Download -Url "https://example.com/file.zip" -Destination "D:\Data" -Quiet -Force

.EXAMPLE
    Start-Download -Url "https://example.com/file.zip" -TempDir "E:\Temp" -MaxRetry 5
#>


function Start-Download {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)][string]$Url,
        [Parameter()][string]$Destination = $PWD.Path,
        [Parameter()][string]$TempPath = $env:TEMP,
        [Parameter()][switch]$NoProgress,
        [Parameter()][switch]$Quiet,
        [Parameter()][switch]$Force,
        [Parameter()][int]$Threads = 1,
        [Parameter()][int]$MaxRetry = 3,
        [Parameter()][int]$Timeout = 30,
        [Parameter()][string]$ExpectedHash,
        [Parameter()][ValidateSet('MD5', 'SHA1', 'SHA256', 'SHA384', 'SHA512', 'CRC32')][string]$HashType = 'MD5',
        [Parameter()][string]$UserAgent = 'Chrome'
    )

    begin {
        function Format-FileSize {
            param([long]$Size)
            
            switch ($Size) {
                { $_ -gt 1TB } { "{0:n2} TB" -f ($_ / 1TB); Break }
                { $_ -gt 1GB } { "{0:n2} GB" -f ($_ / 1GB); Break }
                { $_ -gt 1MB } { "{0:n2} MB" -f ($_ / 1MB); Break }
                { $_ -gt 1KB } { "{0:n2} KB" -f ($_ / 1KB); Break }
                default { "{0} B " -f $_ }
            }
        }

        $userAgents = @{
            'Chrome' = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
            'Firefox' = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0"
            'Edge' = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.2365.92"
            'Safari' = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15"
            'Opera' = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0"
            'Simple' = "Mozilla/5.0"
            'Wget' = "Wget/1.21.4"
            'Curl' = "curl/8.4.0"
            'PS' = "PowerShell/7.4 (Windows NT 10.0; Win64; x64)"
            'None' = $null
        }

        if (($HashType -eq "CRC32") -and -not ([type]::GetType("Win32Api"))) {
            $typeDefinition = "using System;`n" +
                "using System.Runtime.InteropServices;`n" +
                "public class Win32Api {`n" +
                " [DllImport(`"ntdll.dll`")]`n" +
                " public static extern uint RtlComputeCrc32(uint dwInitial, byte[] pData, int iLen);`n" +
                "}"
            Add-Type -TypeDefinition $typeDefinition.Trim() | Out-Null
        }
        
        if ($ExpectedHash) {
            $script:ExpectedHash = $ExpectedHash.ToUpper()
        }
        
        # If UserAgent is a preset name, use the corresponding string
        if ($userAgents.ContainsKey($UserAgent)) {
            $UserAgent = $userAgents[$UserAgent]
        }

        if ($Quiet -and $Verbose) {
            Write-Error "Cannot use Quiet and Verbose at the same time."
            return
        }

        $isPipeline = -not $PSBoundParameters.ContainsKey('Url')
        
        if ($isPipeline) {
            $script:pipelineUrls = @()
        }
    }

    process {
        if ($isPipeline) {
            $script:pipelineUrls += $Url
        }
        
        if ($isPipeline -and $script:pipelineUrls.Count -gt 1 -and $ExpectedHash) {
            Write-Warning "Hash verification is disabled when processing multiple URLs."
            $ExpectedHash = $null
        }

        Write-Verbose "Processing URL: $Url"

        $attempt = 0
        $success = $false

        while ($attempt -lt $MaxRetry -and -not $success) {
            try {
                $attempt++
                if ($attempt -gt 1) {
                    Write-Verbose "Retry attempt $attempt of $MaxRetry"
                    Start-Sleep -Seconds ($attempt * 2)
                }

                $BUFFER_SIZE = 64KB
                $MB = 1024 * 1024

                $request = [System.Net.HttpWebRequest]::Create($Url)
                $request.Method = "HEAD"
                $request.UserAgent = $UserAgent
                $response = $request.GetResponse()
                
                if (Test-Path $Destination -PathType Container) {
                    $fileName = ""
                    
                    $contentDisposition = $response.Headers["Content-Disposition"]
                    if ($contentDisposition -match 'filename=(.+?)$') {
                        $fileName = $matches[1].Trim('"', "'")
                    }
                    
                    if (-not $fileName) {
                        $fileName = [System.IO.Path]::GetFileName([System.Uri]::UnescapeDataString($Url))
                    }
                    
                    if (-not $fileName) {
                        $fileName = "download"
                        
                        $contentType = $response.ContentType
                        $extension = [System.Web.MimeMapping]::GetMimeMapping($contentType)
                        if ($extension) {
                            $fileName = "$fileName$extension"
                        }
                    }
                    
                    $OutFile = Join-Path $Destination $fileName
                }
                else {
                    $OutFile = $Destination
                }

                if ($tempDir -and (Test-Path $tempDir)) {
                    try {
                        Write-Verbose "Cleaning up existing temp directory: $tempDir"
                        Remove-Item -Path $tempDir -Recurse -Force -ErrorAction Stop
                    }
                    catch {
                        Write-Warning "Failed to remove existing temp directory: $_"
                    }
                }

                $contentLength = $response.ContentLength
                $acceptRanges = $response.Headers["Accept-Ranges"]
                $response.Close()

                $totalSize = if ($contentLength -gt 0) {
                    Format-FileSize -Size $contentLength
                }

                if ($contentLength -le 0) {
                    Write-Verbose "Content length is invalid or not provided. Falling back to single-threaded download."
                    $Threads = 0
                }

                if ($acceptRanges -ne "bytes") {
                    Write-Verbose "Server does not support range requests. Falling back to single-threaded download."
                    $Threads = 0
                }

                $downloadTimer = [System.Diagnostics.Stopwatch]::StartNew()

                if (Test-Path $OutFile) {
                    if ($Force) {
                        Write-Verbose "Overwriting file at $OutFile"
                        Remove-Item -Path $OutFile -Force -ErrorAction Ignore
                    }
                    else {
                        Write-Warning "File already exists at $OutFile. Skipping download."
                        return
                    }
                }

                if ($Threads -eq 0) {
                    $request = [System.Net.HttpWebRequest]::Create($Url)
                    $request.UserAgent = $UserAgent
                    $request.Method = "GET"
                    
                    $response = $request.GetResponse()
                    $responseStream = $response.GetResponseStream()
                    $fileStream = [System.IO.File]::Create($OutFile)
                    
                    $bufferSize = 8192
                    $buffer = New-Object byte[] $bufferSize
                    $totalBytesRead = 0
                    $lastUpdate = [DateTime]::Now
                    $lastBytes = 0
                    
                    try {
                        while (($bytesRead = $responseStream.Read($buffer, 0, $buffer.Length)) -gt 0) {
                            $fileStream.Write($buffer, 0, $bytesRead)
                            $totalBytesRead += $bytesRead
                            
                            if (-not $NoProgress) {
                                $now = [DateTime]::Now
                                if (($now - $lastUpdate).TotalMilliseconds -ge 100) {
                                    $progress = if ($contentLength -gt 0) { ($totalBytesRead / $contentLength) * 100 } else { -1 }
                                    $speed = ($totalBytesRead - $lastBytes) / ($now - $lastUpdate).TotalSeconds / 1MB
                                    
                                    $downloadedSize = Format-FileSize -Size $totalBytesRead
                                    
                                    $activity = "Downloading File: $fileName ($downloadedSize"
                                    $activity += if ($contentLength -gt 0) { " of $totalSize" } else { " downloaded" }
                                    $activity += ")"
                                    
                                    $status = if ($contentLength -gt 0) {
                                        "$([math]::Round($progress, 2))% Complete - $([math]::Round($speed, 2)) MB/s"
                                    } else {
                                        "$([math]::Round($speed, 2)) MB/s"
                                    }
                                    
                                    Write-Progress -Activity $activity `
                                        -Status $status `
                                        -PercentComplete $progress
                                    
                                    $lastUpdate = $now
                                    $lastBytes = $totalBytesRead
                                }
                            }
                        }
                    }
                    finally {
                        $fileStream.Close()
                        $responseStream.Close()
                        $response.Close()
                    }
                }
                else {
                    $segmentSize = [math]::Ceiling($contentLength / $Threads)
                    $tempDir = Join-Path $TempPath "DownloadSegments-$(New-Guid)"
                    $segmentSizes = @{}

                    while (Test-Path $tempDir) {
                        try {
                            Write-Verbose "Cleaning up existing temp directory: $tempDir"
                            Remove-Item -Path $tempDir -Recurse -Force -ErrorAction Stop
                        }
                        catch {
                            Write-Warning "Failed to remove existing temp directory: $_"
                            $tempDir = Join-Path $TempPath "DownloadSegments-$(New-Guid)"
                        }
                    }
                    New-Item -ItemType Directory -Path $tempDir | Out-Null

                    $downloadSegment = {
                        param(
                            [string]$url,
                            [string]$tempFile,
                            [long]$start,
                            [long]$end,
                            [int]$bufferSize,
                            [int]$timeout,
                            [string]$userAgent
                        )
                        
                        $request = [System.Net.HttpWebRequest]::Create($url)
                        $request.AddRange($start, $end)
                        $request.Timeout = $timeout * 1000
                        $request.ReadWriteTimeout = $timeout * 1000
                        $request.UserAgent = $userAgent
                        $totalBytes = 0
                        $expectedBytes = $end - $start + 1
                        
                        try {
                            $response = $request.GetResponse()
                            $stream = $response.GetResponseStream()
                            $stream.ReadTimeout = $timeout * 1000
                            $fileStream = [System.IO.File]::Create($tempFile)
                            $buffer = New-Object byte[] $bufferSize
                            
                            while ($totalBytes -lt $expectedBytes) {
                                $remaining = $expectedBytes - $totalBytes
                                $toRead = [Math]::Min($buffer.Length, $remaining)
                                $read = $stream.Read($buffer, 0, $toRead)
                                
                                if ($read -eq 0) { break }
                                
                                $fileStream.Write($buffer, 0, $read)
                                $totalBytes += $read
                                Write-Output @{ BytesRead = $totalBytes }
                            }

                            if ($totalBytes -ne $expectedBytes) {
                                throw "Segment size mismatch: Expected $expectedBytes bytes, got $totalBytes bytes"
                            }
                        }
                        finally {
                            if ($fileStream) { $fileStream.Dispose() }
                            if ($stream) { $stream.Dispose() }
                            if ($response) { $response.Dispose() }
                        }
                    }

                    $jobs = @()
                    $tempFiles = @()

                    for ($i = 0; $i -lt $Threads; $i++) {
                        $start = $i * $segmentSize
                        $end = [Math]::Min(($i + 1) * $segmentSize - 1, $contentLength - 1)
                        $tempFile = Join-Path $tempDir "segment_$i"
                        $tempFiles += $tempFile
                        
                        $segmentSizes[$i] = $end - $start + 1
                        
                        $job = Start-Job -ScriptBlock $downloadSegment -ArgumentList $Url, $tempFile, $start, $end, $BUFFER_SIZE, $Timeout, $UserAgent
                        $jobs += $job
                    }

                    $fileName = [System.IO.Path]::GetFileName($OutFile)

                    $lastUpdate = 0
                    $completedSegments = @{}
                    $lastProgressTime = [DateTime]::Now
                    $segmentLastProgress = @{}
                    $segmentRetries = @{}
                    $maxSegmentRetries = 3

                    function Restart-Segment {
                        param(
                            [int]$segmentIndex,
                            [string]$reason
                        )
                        
                        if (-not $segmentRetries.ContainsKey($segmentIndex)) {
                            $segmentRetries[$segmentIndex] = 0
                        }
                        
                        $segmentRetries[$segmentIndex]++
                        if ($segmentRetries[$segmentIndex] -gt $maxSegmentRetries) {
                            Write-Warning "Segment $segmentIndex failed after $maxSegmentRetries retries: $reason"
                            throw "Download failed - segment $segmentIndex max retries exceeded"
                        }
                        
                        Write-Verbose "Restarting segment $segmentIndex (attempt $($segmentRetries[$segmentIndex]) of $maxSegmentRetries): $reason"
                        
                        # Clean up old job
                        $oldJob = $jobs[$segmentIndex]
                        if ($oldJob) {
                            try {
                                if ($oldJob.State -ne 'Completed') {
                                    $oldJob | Stop-Job -ErrorAction SilentlyContinue
                                }
                                $oldJob | Remove-Job -Force -ErrorAction SilentlyContinue
                            } catch {
                                Write-Warning "Failed to cleanup old job for segment $($segmentIndex): $_"
                            }
                        }
                        
                        # Calculate start and end positions
                        $start = $segmentIndex * $segmentSize
                        $end = [Math]::Min(($segmentIndex + 1) * $segmentSize - 1, $contentLength - 1)
                        $tempFile = Join-Path $tempDir "segment_$segmentIndex"
                        
                        # Start new job
                        $jobs[$segmentIndex] = Start-Job -ScriptBlock $downloadSegment -ArgumentList $Url, $tempFile, $start, $end, $BUFFER_SIZE, $Timeout, $UserAgent
                        
                        # Reset progress tracking
                        $segmentLastProgress[$segmentIndex] = @{
                            LastTime = [DateTime]::Now
                            LastBytes = 0
                            StuckCount = 0
                        }
                    }

                    while ($true) {
                        $totalBytesRead = 0
                        $allComplete = $true
                        $currentTime = [DateTime]::Now
                        
                        # Global timeout check - if no progress
                        if (($currentTime - $lastProgressTime).TotalSeconds -gt $Timeout) {
                            throw "Download timed out - no progress for $Timeout seconds"
                        }
                        
                        for ($i = 0; $i -lt $Threads; $i++) {
                            if ($completedSegments[$i]) {
                                $totalBytesRead += $segmentSizes[$i]
                                continue
                            }
                            
                            $job = $jobs[$i]
                            if (-not $job) {
                                Restart-Segment -segmentIndex $i -reason "Job was lost"
                                $allComplete = $false
                                continue
                            }
                            
                            # Initialize progress tracking for this segment if not exists
                            if (-not $segmentLastProgress.ContainsKey($i)) {
                                $segmentLastProgress[$i] = @{
                                    LastTime = $currentTime
                                    LastBytes = 0
                                    StuckCount = 0
                                }
                            }
                            
                            if ($job.State -eq 'Failed') {
                                $errorMsg = $job.ChildJobs[0].JobStateInfo.Reason.Message
                                Restart-Segment -segmentIndex $i -reason $errorMsg
                                $allComplete = $false
                                continue
                            }
                            
                            # Handle completed jobs that might be stuck
                            if ($job.State -eq 'Completed') {
                                $data = Receive-Job -Job $job -Keep -ErrorAction Stop
                                if (-not $data -or $data.Count -eq 0 -or ($data | Select-Object -Last 1).BytesRead -lt $segmentSizes[$i]) {
                                    Restart-Segment -segmentIndex $i -reason "Completed but did not finish downloading"
                                    $allComplete = $false
                                    continue
                                }
                            }
                            
                            try {
                                $data = Receive-Job -Job $job -Keep -ErrorAction Stop
                                if ($data -and $data.Count -gt 0) {
                                    $lastBytes = ($data | Select-Object -Last 1).BytesRead
                                    
                                    # Check if this segment is making progress
                                    if ($lastBytes -gt $segmentLastProgress[$i].LastBytes) {
                                        $segmentLastProgress[$i].LastTime = $currentTime
                                        $segmentLastProgress[$i].LastBytes = $lastBytes
                                        $segmentLastProgress[$i].StuckCount = 0
                                        $lastProgressTime = $currentTime
                                    } else {
                                        # Check if segment is stuck
                                        $segmentStuckTime = ($currentTime - $segmentLastProgress[$i].LastTime).TotalSeconds
                                        if ($segmentStuckTime -gt $Timeout) {
                                            $segmentLastProgress[$i].StuckCount++
                                            if ($segmentLastProgress[$i].StuckCount -gt 3) {
                                                Restart-Segment -segmentIndex $i -reason "Stuck for too long"
                                                $allComplete = $false
                                                continue
                                            }
                                        }
                                    }
                                    
                                    if ($lastBytes -ge $segmentSizes[$i]) {
                                        $completedSegments[$i] = $true
                                        $totalBytesRead += $segmentSizes[$i]
                                        $segmentLastProgress.Remove($i)
                                        try {
                                            if ($job.State -ne 'Completed') {
                                                $job | Stop-Job -ErrorAction SilentlyContinue
                                            }
                                            $job | Remove-Job -Force -ErrorAction SilentlyContinue
                                            $jobs[$i] = $null
                                        }
                                        catch {
                                            Write-Warning "Failed to cleanup completed job $($i): $_"
                                        }
                                    } else {
                                        $allComplete = $false
                                        $totalBytesRead += $lastBytes
                                    }
                                } else {
                                    $allComplete = $false
                                    
                                    # Check if segment has been silent too long
                                    $segmentStuckTime = ($currentTime - $segmentLastProgress[$i].LastTime).TotalSeconds
                                    if ($segmentStuckTime -gt 10) {
                                        $segmentLastProgress[$i].StuckCount++
                                        if ($segmentLastProgress[$i].StuckCount -gt 3) {
                                            Restart-Segment -segmentIndex $i -reason "No progress for too long"
                                            continue
                                        }
                                    }
                                }
                            }
                            catch {
                                Restart-Segment -segmentIndex $i -reason "Error: $_"
                                $allComplete = $false
                                continue
                            }
                        }
                        
                        if ($allComplete) { break }
                        
                        if (-not $NoProgress) {
                            $progress = [Math]::Min(($totalBytesRead / $contentLength) * 100, 100)
                            $speed = ($totalBytesRead - $lastUpdate) / 0.5 # MB/s
                            $lastUpdate = $totalBytesRead
                            $downloadedSize = Format-FileSize -Size $totalBytesRead
                            
                            Write-Progress -Activity "Downloading File: $fileName ($downloadedSize of $totalSize)" `
                                          -Status "$([math]::Round($progress, 2))% Complete - $([math]::Round($speed / $MB, 2)) MB/s" `
                                          -PercentComplete $progress
                        }
                        
                        Start-Sleep -Milliseconds 500
                    }

                    $finalFile = [System.IO.File]::Create($OutFile)
                    try {
                        for ($i = 0; $i -lt $Threads; $i++) {
                            if (-not $NoProgress) {
                                Write-Progress -Activity "Merging segments: $fileName" `
                                              -Status "Processing segment $($i + 1) of $Threads" `
                                              -PercentComplete (($i / $Threads) * 100)
                            }
                            
                            $tempFile = Join-Path $tempDir "segment_$i"
                            $expectedSize = $segmentSizes[$i]
                            
                            if (-not (Test-Path $tempFile)) {
                                throw "Missing segment file: $tempFile"
                            }
                            
                            $bytes = [System.IO.File]::ReadAllBytes($tempFile)
                            if ($bytes.Length -eq 0) {
                                throw "Empty segment file: $tempFile"
                            }
                            if ($bytes.Length -ne $expectedSize) {
                                throw "Segment size mismatch: Expected $expectedSize, got $($bytes.Length)"
                            }
                            
                            $finalFile.Write($bytes, 0, $bytes.Length)
                        }
                    }
                    finally {
                        $finalFile.Close()
                        Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
                    }

                    if (-not $NoProgress) {
                        Write-Progress -Activity "Downloading File" -Completed
                    }

                    $downloadTimer.Stop()
                }

                # Hash verification and verbose output moved outside both download methods
                if ($ExpectedHash -or ($PSCmdlet.MyInvocation.BoundParameters["Verbose"].IsPresent)) {
                    if ($HashType -eq "CRC32") {
                        $allBytes = [System.IO.File]::ReadAllBytes($OutFile)
                        $crc32 = [Win32Api]::RtlComputeCrc32(0, $allBytes, $allBytes.Length)
                        $actualHash = $crc32.ToString("X8")
                    } else {
                        $actualHash = (Get-FileHash -Path $OutFile -Algorithm $HashType).Hash
                    }
                }

                if ($ExpectedHash) {
                    Write-Verbose "Verifying file hash..."
                    if ($actualHash -ne $ExpectedHash) {
                        Write-Warning "Hash verification failed! Expected: $ExpectedHash, Got: $actualHash"
                        $success = $false
                        continue
                    }
                    Write-Verbose "Hash verification successful"
                }

                if ($PSCmdlet.MyInvocation.BoundParameters["Verbose"].IsPresent) {
                    Write-Output "`nFile downloaded successfully."
                    $elapsed = $downloadTimer.Elapsed
                    $formattedTime = if ($elapsed.Hours -gt 0) {
                        "{0} hour{1} {2} minute{3} {4:N3} seconds" -f 
                            $elapsed.Hours,
                            $(if ($elapsed.Hours -eq 1) {""} else {"s"}),
                            $elapsed.Minutes,
                            $(if ($elapsed.Minutes -eq 1) {""} else {"s"}),
                            ($elapsed.Seconds + $elapsed.Milliseconds / 1000)
                    } elseif ($elapsed.Minutes -gt 0) {
                        "{0} minute{1} {2:N3} seconds" -f 
                            $elapsed.Minutes,
                            $(if ($elapsed.Minutes -eq 1) {""} else {"s"}),
                            ($elapsed.Seconds + $elapsed.Milliseconds / 1000)
                    } else {
                        "{0:N3} seconds" -f ($elapsed.Seconds + $elapsed.Milliseconds / 1000)
                    }
                    
                    Write-Output "Path: $OutFile"
                    Write-Output "Size: $(Format-FileSize -Size ((Get-Item $OutFile).Length))"
                    Write-Output "Elapsed Time: $formattedTime"
                    Write-Output "$($HashType): $actualHash`n"
                }
                else {
                    if (-not $Quiet) {
                        Write-Output "File downloaded successfully."
                    }
                }

                $success = $true
            }
            catch {
                if ($attempt -ge $MaxRetry) {
                    Write-Error "Failed after $MaxRetry attempts: $_"
                    throw
                }
                Write-Warning "Download failed (attempt $attempt of $MaxRetry): $_"
                
                if (Test-Path $tempDir) {
                    Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
                }
                if (Test-Path $OutFile) {
                    Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue
                }
            }
            finally {
                $jobs | Where-Object { $_ } | ForEach-Object {
                    try {
                        if ($_.State -ne 'Completed') {
                            $_ | Stop-Job -ErrorAction SilentlyContinue
                        }
                        $_ | Remove-Job -Force -ErrorAction SilentlyContinue
                        Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
                    }
                    catch {
                        Write-Warning "Failed to cleanup job: $_"
                    }
                }
                $jobs = @()
            }
        }
    }
}