Public/Save-MusicForProgramming.ps1

function Save-MusicForProgramming {
    <#
    .SYNOPSIS
        Downloads Music For Programming episodes to a local directory.

    .DESCRIPTION
        Downloads one or more episodes from musicforprogramming.net with a live
        progress bar. Files are saved using the standard naming convention
        (music_for_programming_NN-artist.mp3) so they work seamlessly with
        Get-LocalMusicForProgramming and Start-MusicForProgramming -LocalPath.

        Accepts episode objects from Get-MusicForProgramming via the pipeline,
        or specify episodes by number with -EpisodeNumber, or use -All to grab
        everything.

        Skips files that already exist unless -Force is given.

    .PARAMETER InputObject
        Episode objects piped from Get-MusicForProgramming.

    .PARAMETER EpisodeNumber
        One or more episode numbers to download.

    .PARAMETER All
        Download every episode from the RSS feed.

    .PARAMETER Path
        Destination directory. Created if it does not exist.
        Defaults to the current directory.

    .PARAMETER Force
        Overwrite files that already exist locally.

    .PARAMETER PassThru
        Return a FileInfo object for each downloaded file.

    .EXAMPLE
        Save-MusicForProgramming -EpisodeNumber 78 -Path ~/Music/MFP

        Downloads episode 78 to ~/Music/MFP.

    .EXAMPLE
        Save-MusicForProgramming -EpisodeNumber 1, 2, 3 -Path ~/Music/MFP

        Downloads episodes 1, 2, and 3.

    .EXAMPLE
        Get-MusicForProgramming | Save-MusicForProgramming -Path ~/Music/MFP

        Downloads all episodes (piped from Get-MusicForProgramming).

    .EXAMPLE
        Get-MusicForProgramming | Where-Object Title -like '*Datassette*' | Save-MusicForProgramming -Path ~/Music/MFP

        Downloads only Datassette episodes.

    .EXAMPLE
        Save-MusicForProgramming -All -Path ~/Music/MFP -PassThru | Select-Object Name, Length

        Downloads everything and returns FileInfo objects for each saved file.
    #>

    [CmdletBinding(DefaultParameterSetName = 'ByEpisode', SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(ValueFromPipeline, ParameterSetName = 'ByEpisode')]
        [PSCustomObject[]]$InputObject,

        [Parameter(Mandatory, ParameterSetName = 'ByNumber')]
        [ArgumentCompleter({
            param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
            (Get-MusicForProgramming).EpisodeNumber |
                Where-Object { "$_" -like "$wordToComplete*" } |
                Sort-Object
        })]
        [int[]]$EpisodeNumber,

        [Parameter(Mandatory, ParameterSetName = 'All')]
        [switch]$All,

        [Parameter()]
        [string]$Path = $script:DefaultMusicPath,

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [switch]$PassThru
    )

    begin {
        # Ensure destination directory exists
        if (-not (Test-Path $Path -PathType Container)) {
            Write-Verbose "Creating directory: $Path"
            New-Item -ItemType Directory -Path $Path -Force | Out-Null
        }
        $resolvedPath = (Resolve-Path $Path).Path

        # For ByNumber / All: collect all episodes up front so we can show X/Y progress
        $queuedEpisodes = [System.Collections.Generic.List[PSCustomObject]]::new()

        if ($PSCmdlet.ParameterSetName -eq 'ByNumber') {
            foreach ($num in $EpisodeNumber) {
                $ep = Get-MusicForProgramming -EpisodeNumber $num
                if ($ep) { $queuedEpisodes.Add($ep) }
                else { Write-Warning "Episode $num not found in the RSS feed." }
            }
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'All') {
            Write-Verbose "Fetching full episode list for -All"
            Get-MusicForProgramming | ForEach-Object { $queuedEpisodes.Add($_) }
        }
    }

    process {
        # Pipeline input goes into the queue too
        if ($PSCmdlet.ParameterSetName -eq 'ByEpisode' -and $InputObject) {
            foreach ($ep in $InputObject) { $queuedEpisodes.Add($ep) }
        }
    }

    end {
        if ($queuedEpisodes.Count -eq 0) {
            Write-Verbose "No episodes to download."
            return
        }

        $total  = $queuedEpisodes.Count
        $index  = 0

        foreach ($episode in $queuedEpisodes) {
            $index++
            $fileName  = ($episode.Url -split '/')[-1]
            $outFile   = Join-Path $resolvedPath $fileName
            $label     = $episode.FullTitle

            # Outer progress bar — overall queue
            $outerStatus = "Episode $index of $total — $label"
            Write-Progress -Id 0 -Activity 'Music For Programming' `
                -Status $outerStatus `
                -PercentComplete ([int](($index - 1) / $total * 100))

            if ((Test-Path $outFile) -and -not $Force) {
                Write-Verbose "Skipping (already exists): $outFile"
                Write-Host " [skip] $label" -ForegroundColor DarkGray
                if ($PassThru) { Get-Item $outFile }
                continue
            }

            if (-not $PSCmdlet.ShouldProcess($label, 'Download')) { continue }

            try {
                Invoke-MFPDownload -Url $episode.Url -OutFile $outFile -Label $label -OuterProgressId 0
                Write-Host " [done] $label" -ForegroundColor Green
                if ($PassThru) { Get-Item $outFile }
            }
            catch {
                Write-Error "Failed to download '$label': $_"
                # Remove partial file if it exists
                if (Test-Path $outFile) { Remove-Item $outFile -Force -ErrorAction SilentlyContinue }
            }
        }

        # Clear outer progress
        Write-Progress -Id 0 -Activity 'Music For Programming' -Completed
    }
}

# ---------------------------------------------------------------------------
# Private helper — not exported
# ---------------------------------------------------------------------------

function Invoke-MFPDownload {
    <#
        Streams a URL to disk with a Write-Progress inner progress bar.
        Uses HttpWebRequest for cross-platform compatibility in PS 5.1+.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$Url,
        [Parameter(Mandatory)] [string]$OutFile,
        [Parameter(Mandatory)] [string]$Label,
        [Parameter()]          [int]   $OuterProgressId = -1
    )

    $innerId = $OuterProgressId + 1

    $request          = [System.Net.HttpWebRequest]::Create($Url)
    $request.Method   = 'GET'
    $request.UserAgent = 'MusicForProgrammingPS/0.1'

    $response   = $request.GetResponse()
    $totalBytes = $response.ContentLength   # -1 if unknown

    $inStream  = $response.GetResponseStream()
    $outStream = [System.IO.File]::Create($OutFile)

    $buffer    = [byte[]]::new(81920)   # 80 KB chunks
    $totalRead = 0L

    try {
        do {
            $read = $inStream.Read($buffer, 0, $buffer.Length)
            if ($read -gt 0) {
                $outStream.Write($buffer, 0, $read)
                $totalRead += $read

                if ($totalBytes -gt 0) {
                    $pct      = [int]($totalRead / $totalBytes * 100)
                    $mbDone   = [math]::Round($totalRead  / 1MB, 1)
                    $mbTotal  = [math]::Round($totalBytes / 1MB, 1)
                    $status   = "$mbDone MB / $mbTotal MB"
                }
                else {
                    $pct    = -1
                    $status = "$([math]::Round($totalRead / 1MB, 1)) MB downloaded"
                }

                $progressParams = @{
                    Id               = $innerId
                    Activity         = $Label
                    Status           = $status
                }
                if ($OuterProgressId -ge 0)  { $progressParams['ParentId']        = $OuterProgressId }
                if ($pct -ge 0)              { $progressParams['PercentComplete']  = $pct }

                Write-Progress @progressParams
            }
        } while ($read -gt 0)
    }
    finally {
        $outStream.Flush()
        $outStream.Dispose()
        $inStream.Dispose()
        $response.Dispose()
        Write-Progress -Id $innerId -Activity $Label -Completed
    }
}