Public/Authoral/Invoke-Download.ps1

function Invoke-Download {
    <#
    .SYNOPSIS
        Baixa um arquivo via BITS, com retomada após intermitência de rede.
    .DESCRIPTION
        Cria ou reutiliza um job do usuário atual com a mesma URL e destino.
        Aguarda a transferência e entrega o arquivo com Complete-BitsTransfer.
        Erros transitórios são tratados pelo BITS com sua política de retry.
        Ctrl+C interrompe a espera sem remover o job; execute novamente com os
        mesmos parâmetros para acompanhar e concluir a transferência pendente.
    .PARAMETER Uri
        URL HTTP ou HTTPS do arquivo.
    .PARAMETER Destination
        Caminho do arquivo de saída, incluindo o nome. A pasta deve existir.
        Arquivos já existentes não são sobrescritos.
    .PARAMETER PollIntervalSeconds
        Intervalo de atualização do progresso. Padrão: 5 segundos.
    .PARAMETER RetryIntervalSeconds
        Intervalo mínimo entre tentativas do BITS. Padrão: 60 segundos.
        Aplica-se somente a jobs novos.
    .PARAMETER RetryTimeoutSeconds
        Prazo de retry após erro transitório. Padrão: 86400 segundos (24 horas).
        Aplica-se somente a jobs novos; não é um limite total do download.
    .EXAMPLE
        Invoke-Download -Uri 'https://isosmint.ic.ufmt.br/stable/22.3/linuxmint-22.3-cinnamon-64bit.iso' -Destination '.\linuxmint-22.3-cinnamon-64bit.iso'
        Baixa a ISO ou acompanha o job pendente para a mesma URL e destino.
    .EXAMPLE
        Invoke-Download -Uri 'https://example.com/file.zip' -Destination 'D:\Downloads\file.zip' -RetryTimeoutSeconds 172800
        Permite até 48 horas de retry após erro transitório em um job novo.
    .OUTPUTS
        System.IO.FileInfo. Arquivo concluído.
    .NOTES
        Requer Windows e o módulo BitsTransfer. A retomada depende do job BITS
        preservado, do servidor e das políticas do serviço. Não importa arquivos
        parciais de outros programas. Fechar a sessão ou reiniciar o computador
        não garante execução contínua; volte a chamar a função ao entrar novamente.
        Jobs em erro são preservados para diagnóstico e retomada explícita via
        Resume-BitsTransfer, ou descarte via Remove-BitsTransfer.
    .LINK
        https://learn.microsoft.com/powershell/module/bitstransfer/start-bitstransfer
    #>

    # Documentação XML auxiliar; Get-Help utiliza o bloco acima.
    <#
    <summary>Baixa um arquivo com retomada gerenciada pelo BITS.</summary>
    <param name="Uri">URL HTTP ou HTTPS de origem.</param>
    <param name="Destination">Caminho completo ou relativo do arquivo de saída.</param>
    <param name="PollIntervalSeconds">Intervalo de atualização do progresso.</param>
    <param name="RetryIntervalSeconds">Intervalo mínimo de retry para jobs novos.</param>
    <param name="RetryTimeoutSeconds">Prazo de retry para jobs novos.</param>
    <returns>System.IO.FileInfo do arquivo concluído.</returns>
    <exception>Falha de validação, erro permanente ou cancelamento do job.</exception>
    #>

    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [ValidateScript({ $_.IsAbsoluteUri -and $_.Scheme -in @('http', 'https') })]
        [uri]$Uri,

        [Parameter(Mandatory, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [string]$Destination,

        [ValidateRange(1, 3600)]
        [int]$PollIntervalSeconds = 5,

        [ValidateRange(60, 2147483647)]
        [int]$RetryIntervalSeconds = 60,

        [ValidateRange(60, 2147483647)]
        [int]$RetryTimeoutSeconds = 86400
    )

    if ($RetryTimeoutSeconds -lt $RetryIntervalSeconds) {
        throw 'RetryTimeoutSeconds deve ser maior ou igual a RetryIntervalSeconds.'
    }

    Import-Module BitsTransfer -ErrorAction Stop
    $provider = $null
    $drive = $null
    $targetPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath(
        $Destination, [ref]$provider, [ref]$drive
    )
    if ($provider.Name -ne 'FileSystem') {
        throw 'O destino deve pertencer ao sistema de arquivos.'
    }
    $parentPath = Split-Path -Parent $targetPath
    if (-not (Test-Path -LiteralPath $parentPath -PathType Container)) {
        throw "A pasta de destino não existe: $parentPath"
    }
    if (Test-Path -LiteralPath $targetPath) {
        throw "O destino já existe: $targetPath"
    }

    $jobs = @(Get-BitsTransfer -ErrorAction Stop | Where-Object {
        @($_.FileList | Where-Object { $_.LocalName -ieq $targetPath }).Count -gt 0
    })
    if ($jobs.Count -gt 1) {
        throw 'Mais de um job BITS usa esse destino. Resolva os jobs antes de continuar.'
    }
    if ($jobs.Count -eq 1) {
        $job = $jobs[0]
        $files = @($job.FileList)
        if ($job.TransferType -ne 'Download' -or $files.Count -ne 1 -or
            $files[0].RemoteName -cne $Uri.AbsoluteUri) {
            throw 'Outro job BITS usa esse destino com origem ou configuração diferente.'
        }
    } else {
        $parameters = @{
            Source = $Uri.AbsoluteUri
            Destination = $targetPath
            DisplayName = 'Invoke-Download: ' + [System.IO.Path]::GetFileName($targetPath)
            Description = 'Download resiliente iniciado pelo psrod'
            Asynchronous = $true
            RetryInterval = $RetryIntervalSeconds
            RetryTimeout = $RetryTimeoutSeconds
            ErrorAction = 'Stop'
        }
        $job = Start-BitsTransfer @parameters
    }

    Write-Verbose "Job BITS: $($job.JobId)"
    try {
        while ($true) {
            $job = Get-BitsTransfer -Id $job.JobId -ErrorAction Stop
            $percent = -1
            # BITS usa UInt64.MaxValue quando o tamanho ainda é desconhecido.
            if ($job.BytesTotal -gt 0 -and $job.BytesTotal -lt [uint64]::MaxValue) {
                $percent = [int][math]::Min(100, 100.0 * $job.BytesTransferred / $job.BytesTotal)
            }
            Write-Progress -Activity 'Download via BITS' -Status "$($job.JobState): $($job.BytesTransferred) bytes" -PercentComplete $percent

            switch ([string]$job.JobState) {
                'Transferred' {
                    Complete-BitsTransfer -BitsJob $job -ErrorAction Stop
                    return Get-Item -LiteralPath $targetPath -ErrorAction Stop
                }
                'Suspended' {
                    Resume-BitsTransfer -BitsJob $job -Asynchronous -ErrorAction Stop | Out-Null
                }
                'TransientError' {
                    Write-Verbose 'Falha transitória; aguardando o retry automático do BITS.'
                }
                'Error' {
                    throw "Falha no job $($job.JobId): $($job.ErrorDescription) (código $($job.ErrorCode)). O job foi preservado."
                }
                'Cancelled' { throw 'O download foi cancelado.' }
                'Acknowledged' { throw 'O job foi concluído por outro processo.' }
            }
            Start-Sleep -Seconds $PollIntervalSeconds
        }
    } finally {
        Write-Progress -Activity 'Download via BITS' -Completed
    }
}