Public/Network/WebTools/Start-DownloadWithRetry.ps1

function Start-DownloadWithRetry {
  <#
    .SYNOPSIS
      Downloads a file from a specified URL with retries.
 
    .DESCRIPTION
      The Start-DownloadWithRetry cmdlet attempts to download a file from the specified URL to a local path.
      It includes retry logic for handling transient failures, allowing you to specify the maximum number of retries and the delay between attempts.
 
    .EXAMPLE
      Start-DownloadWithRetry -Url "https://sample-videos.com/video321/mp4/720/big_buck_bunny_720p_5mb.mp4"
 
      Downloads a video to mysamplevideo.mp4 from the specified URL to the system's temporary directory.
 
    .EXAMPLE
      Start-DownloadWithRetry -Url "https://sample-videos.com/video321/mp4/720/big_buck_bunny_720p_5mb.mp4" -Name "mysamplevideo.mp4" -DownloadPath $pwd
 
      Downloads a video to mysamplevideo.mp4 from the specified URL and saves it as 'mysamplevideo.mp4' in the $pwd directory.
 
    .EXAMPLE
      $link = (iwr -Method Get -Uri https://catalog.data.gov/dataset/national-student-loan-data-system-722b0 -SkipHttpErrorCheck -verbose:$false).Links.Where({ $_.href.EndsWith(".xls") })[0].href
      Start-DownloadWithRetry -Url $link -Retries 3 -SecondsBetweenAttempts 10
 
      Attempts to download the file with a maximum of 3 retries, waiting 10 seconds between each attempt.
 
    .EXAMPLE
      Start-DownloadWithRetry -Url $link -WhatIf
 
      Displays what would happen if the cmdlet runs without actually downloading the file.
 
    .EXAMPLE
      Start-DownloadWithRetry -Url $link -Verbose
 
      Provides detailed output about the download process, including retry attempts and success/failure messages.
 
    .NOTES
      Author: Alain Herve
      Version: 1.0
 
    .LINK
      Online Version: https://github.com/alainQtec/cliHelper.Core/blob/main/Public/Network/WebTools/Start-DownloadWithRetry.ps1
  #>

  [Alias('DownloadWithRetry')][OutputType([IO.FileInfo])]
  [CmdletBinding(ConfirmImpact = 'Medium', SupportsShouldProcess = $true)]
  Param(
    # Specifies the URL of the file to download. This parameter is mandatory.
    [Parameter(Mandatory = $true, Position = 0)]
    [ValidateScript({
        if ([xcrypt]::IsValidUrl($_)) {
          return $true
        }; throw [System.ArgumentException]::new("Please Provide a valid URL: $_", "Url")
      })]
    [Alias('uri')][ValidateNotNullOrWhiteSpace()]
    [string]$Url,

    # Specifies the name of the file to save locally. If not provided, the file name will be derived from the URL.
    [Parameter(Mandatory = $false, Position = 1)]
    [Alias('n')][ValidateNotNullOrWhiteSpace()]
    [string]$Name,

    # Specifies the local directory where the file will be saved. Defaults to the current directory.
    [Parameter(Mandatory = $false, Position = 2)]
    [Alias('dlPath')][ValidateNotNullOrWhiteSpace()]
    [string]$DownloadPath = (Get-Location).Path,

    # Specifies the maximum number of retry attempts if the download fails. Defaults to 5.
    [Parameter(Mandatory = $false, Position = 3)]
    [Alias('r')]
    [int]$Retries = 5,

    # Specifies the delay, in seconds, between retry attempts. Defaults to 5 seconds.
    [Parameter(Mandatory = $false, Position = 4)]
    [Alias('s', 'timeout')]
    [int]$SecondsBetweenAttempts = 5,

    # Specifies a custom message to display during the download process.
    [Parameter(Mandatory = $false, Position = 5)]
    [Alias('m')]
    [string]$Message = "Downloading file",

    # Allows cancellation of the download operation using a System.Threading.CancellationToken.
    [Parameter(Mandatory = $false, Position = 6)]
    [System.Threading.CancellationToken]$CancellationToken = [System.Threading.CancellationToken]::None
  )

  Process {
    if ([String]::IsNullOrEmpty($Name)) { $Name = [IO.Path]::GetFileName($Url) }
    $OutputFilePath = [IO.Path]::Combine([xcrypt]::GetUnResolvedPath($DownloadPath), $Name)
    $url_verbose_txt = $url | Invoke-PathShortener
    $outfile_verbose_txt = $OutputFilePath | Invoke-PathShortener
    $GetfileSize = {
      param([long]$Bytes)
      switch ($bytes) {
        { $bytes -lt 1MB } { return "$([Math]::Round($bytes / 1KB, 2)) KB" }
        { $bytes -lt 1GB } { return "$([Math]::Round($bytes / 1MB, 2)) MB" }
        { $bytes -lt 1TB } { return "$([Math]::Round($bytes / 1GB, 2)) GB" }
        Default { return "$([Math]::Round($bytes / 1TB, 2)) TB" }
      }
    }
    $dlEvent = [PSCustomObject]@{ size_str = [string]::Empty }
    $DownloadScript = {
      param([uri]$Uri, [string]$FilePath)
      try {
        $file_name = $FilePath | Split-Path -Leaf
        $webClient = [System.Net.WebClient]::new()
        # $webClient.Credentials = $login
        $task = $webClient.DownloadFileTaskAsync($Uri, $FilePath)
        Register-ObjectEvent -InputObject $webClient -EventName DownloadProgressChanged -SourceIdentifier WebClient.DownloadProgressChanged | Out-Null
        $verbose ? (Write-Console " Attempting to download '$url_verbose_txt' to '$outfile_verbose_txt'..." -f SteelBlue) : $null
        $dlEvent.PsObject.Properties.Add([PSScriptProperty]::new('Data', {
              $e = Get-Event -SourceIdentifier WebClient.DownloadProgressChanged -ea Ignore
              if ($e) {
                return $e[-1].SourceEventArgs
              }; return $null
            }
          )
        )
        While (!$task.IsCompleted) {
          if ($null -ne $dlEvent.Data) {
            $ReceivedData = $dlEvent.Data.BytesReceived
            $TotalToReceive = $dlEvent.Data.TotalBytesToReceive
            $TotalPercent = $dlEvent.Data.ProgressPercentage
            if ($null -ne $ReceivedData) {
              $dlEvent.size_str = "{0} / {1}" -f $($GetfileSize.Invoke($ReceivedData)), $($GetfileSize.Invoke($TotalToReceive))
              [ProgressUtil]::WriteProgressBar([int]$TotalPercent, " Downloading : $($dlEvent.size_str)")
            }
          }
          [System.Threading.Thread]::Sleep(50)
        }
      } catch {
        Write-Console "Error occurred: $_" -f Salmon
        throw $_
      } finally {
        [ProgressUtil]::WriteProgressBar(100, $true, " Downloaded $($dlEvent.size_str)", $true)
        if ([IO.File]::Exists($FilePath)) {
          $verbose ? (Write-Console " OutPath: '$FilePath'" -f SteelBlue) : $null
        }
        Invoke-Command { Unregister-Event -SourceIdentifier WebClient.DownloadProgressChanged -Force -ea Ignore; $webClient.Dispose() } -ea Ignore
      }
      if ([IO.File]::Exists($FilePath)) {
        return Get-Item $FilePath
      } else {
        return [IO.FileInfo]::new($FilePath)
      }
    }

    try {
      $SplatParams = @{
        ScriptBlock            = $DownloadScript
        ArgumentList           = @($Url, $OutputFilePath)
        MaxAttempts            = $Retries
        SecondsBetweenAttempts = $SecondsBetweenAttempts
        Message                = $Message
        CancellationToken      = $CancellationToken
        Verbose                = $VerbosePreference
      }
      $result = Invoke-RetriableCommand @SplatParams
    } catch {
      throw $_
    }
  }

  end {
    return $result.Output
  }
}