Public/Invoke-TrackStashTranscode.ps1
|
<# .SYNOPSIS Transcodes audio files with ffmpeg while preserving metadata. .DESCRIPTION Transcodes one or more input files to FLAC, AIFF, WAV, or MP3 through ffmpeg. Metadata is preserved by default using -map_metadata 0. The cmdlet supports pipeline input and continues processing remaining files when a per-file error occurs. .PARAMETER InputPath One or more input file paths. Accepts pipeline input and objects with Path/FullName. .PARAMETER OutputDirectory Existing output directory where transcoded files are written. .PARAMETER Format Target format: Flac, Aiff, Wav, or Mp3. .PARAMETER BitrateKbps MP3 bitrate in kbps. Defaults to 320. .PARAMETER SampleRate Optional output sample rate. If omitted, source sample rate is preserved. .PARAMETER Force Overwrite existing output files. .OUTPUTS PSCustomObject containing per-file results, summary, and aggregate exit code. .EXAMPLE Get-ChildItem ./input/*.flac | Invoke-TrackStashTranscode -OutputDirectory ./out -Format Mp3 -Verbose #> function Invoke-TrackStashTranscode { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('Path', 'FullName')] [string[]]$InputPath, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$OutputDirectory, [Parameter(Mandatory)] [ValidateSet('Flac', 'Aiff', 'Wav', 'Mp3')] [string]$Format, [Parameter()] [ValidateRange(8, 320)] [int]$BitrateKbps = 320, [Parameter()] [ValidateRange(8000, 384000)] [int]$SampleRate, [Parameter()] [switch]$Force ) begin { $ffmpegPath = Resolve-FfmpegPath $startedUtc = (Get-Date).ToUniversalTime() if (-not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { throw [System.IO.DirectoryNotFoundException]::new("Output directory not found: $OutputDirectory") } $resolvedOutputDirectory = (Resolve-Path -LiteralPath $OutputDirectory).Path $collectedInputs = [System.Collections.Generic.List[string]]::new() } process { foreach ($path in $InputPath) { if ([string]::IsNullOrWhiteSpace($path)) { continue } [void]$collectedInputs.Add($path) } } end { if ($collectedInputs.Count -eq 0) { throw [System.ArgumentException]::new('At least one input file path is required.') } $results = @() $transcodeOptions = $null if ($PSBoundParameters.ContainsKey('SampleRate')) { $transcodeOptions = Convert-TranscodeOptions -TargetFormat $Format -BitrateKbps $BitrateKbps -SampleRate $SampleRate } else { $transcodeOptions = Convert-TranscodeOptions -TargetFormat $Format -BitrateKbps $BitrateKbps } foreach ($path in $collectedInputs) { $result = [PSCustomObject]@{ InputPath = $path OutputPath = $null Format = $Format Success = $false Skipped = $false ExitCode = 1 Error = $null } try { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw [System.IO.FileNotFoundException]::new("Input file not found: $path") } $resolvedInputPath = (Resolve-Path -LiteralPath $path).Path $outputName = "{0}.{1}" -f [System.IO.Path]::GetFileNameWithoutExtension($resolvedInputPath), $transcodeOptions.Extension $outputPath = Join-Path -Path $resolvedOutputDirectory -ChildPath $outputName $result.OutputPath = $outputPath if ((Test-Path -LiteralPath $outputPath -PathType Leaf) -and -not $Force) { throw [System.IO.IOException]::new("Output file already exists: $outputPath. Use -Force to overwrite.") } $overwriteArgument = '-n' if ($Force) { $overwriteArgument = '-y' } $arguments = @( '-hide_banner', $overwriteArgument, '-i', $resolvedInputPath, '-map_metadata', '0' ) $arguments += $transcodeOptions.AudioArguments $arguments += $transcodeOptions.MuxerArguments $arguments += $outputPath if ($PSCmdlet.ShouldProcess($resolvedInputPath, "Transcode to $Format")) { $processResult = Start-FfmpegProcess -FfmpegPath $ffmpegPath -ArgumentList $arguments -InputPath $resolvedInputPath -OutputPath $outputPath $result.Success = $processResult.Success $result.ExitCode = $processResult.ExitCode if (-not $processResult.Success) { $result.Error = if ([string]::IsNullOrWhiteSpace($processResult.StdErr)) { "ffmpeg exited with code $($processResult.ExitCode)." } else { $processResult.StdErr.Trim() } Write-Error -Message "Transcode failed for '$resolvedInputPath': $($result.Error)" -ErrorAction Continue } } else { $result.Skipped = $true $result.Success = $true $result.ExitCode = 0 } } catch { $result.Error = $_.Exception.Message $result.Success = $false $result.Skipped = $false $result.ExitCode = 1 Write-Error -Message "Transcode failed for '$path': $($_.Exception.Message)" -ErrorAction Continue } $results += $result } $processed = @($results | Where-Object { -not $_.Skipped }) $summary = [PSCustomObject]@{ Total = $results.Count Processed = $processed.Count Succeeded = @($processed | Where-Object { $_.Success }).Count Failed = @($processed | Where-Object { -not $_.Success }).Count Skipped = @($results | Where-Object { $_.Skipped }).Count StartedUtc = $startedUtc EndedUtc = (Get-Date).ToUniversalTime() } $aggregateExitCode = 0 if ($summary.Failed -gt 0) { $aggregateExitCode = 1 } return [PSCustomObject]@{ Results = $results Summary = $summary ExitCode = $aggregateExitCode } } } |