Last active
August 18, 2026 05:26
-
-
Save h8rt3rmin8r/2c3ea4654e47684b2695653585aa4f5a to your computer and use it in GitHub Desktop.
Remux browser-incompatible video containers to faststart MP4 in place when viewed in a browser.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| Remux browser-incompatible video containers to faststart MP4 in place. | |
| .DESCRIPTION | |
| Walks a target directory tree, probes every matching video file with | |
| ffprobe, and identifies files whose container is not MP4. A common case | |
| is an MPEG transport stream carrying an .mp4 extension: the codecs are | |
| fine, the extension lies, and Chrome renders an empty player shell | |
| because it cannot demux MPEG-TS natively. | |
| Each non-compliant file is remuxed with ffmpeg using stream copy, so no | |
| re-encoding occurs and no visual quality is lost. The output is written | |
| to a local staging directory first, verified with a second ffprobe pass, | |
| and only then copied back over the original. Staging locally matters | |
| when the target is a network or object-storage mount: the +faststart | |
| flag requires ffmpeg to seek backwards while finalizing, which many | |
| mounts handle poorly or not at all. | |
| Files that already report an MP4 container are left untouched. Files | |
| whose video codec is not H.264 or whose audio codec is not AAC are | |
| reported and skipped, because stream copy alone would not make them | |
| browser-playable and transcoding is deliberately out of scope for this | |
| script. | |
| Every file is confined to the target tree. The resolved full path of | |
| each candidate is compared against the resolved root before any work | |
| begins, and reparse points (symlinks and junctions) are skipped, so a | |
| link pointing outside the tree cannot pull the run out of scope. | |
| Overwriting the original is destructive, so the script declares | |
| SupportsShouldProcess and gates the remux-and-replace unit behind | |
| $PSCmdlet.ShouldProcess. Running with -WhatIf performs the full probe | |
| pass and reports exactly which files would be converted without | |
| remuxing or writing anything, which makes it the intended way to audit | |
| a collection before committing to the work. | |
| Requires ffmpeg and ffprobe on PATH, or explicit paths supplied through | |
| -FFmpegPath and -FFprobePath. Both must be native Windows builds if the | |
| target is a mapped drive letter; a WSL-only ffmpeg will not resolve a | |
| Windows drive path. | |
| Exit codes: 0 success, 1 one or more files failed to convert or failed | |
| post-conversion verification, 2 an environment precondition was not met | |
| (target directory missing, ffmpeg or ffprobe not found, staging | |
| directory not creatable). | |
| .PARAMETER Path | |
| Root directory to scan. The scan recurses into subdirectories but never | |
| leaves this tree. Must exist. Mandatory, so the scope of a run is always | |
| stated explicitly at the call site. | |
| Alias: p | |
| .PARAMETER Pattern | |
| Glob pattern used to enumerate candidate files (passed to Get-ChildItem | |
| -Filter). Deliberate wildcard use; the literal-path rule applies to the | |
| handling of each resolved file, not to this enumeration filter. | |
| Default: '*.mp4'. | |
| Alias: f | |
| .PARAMETER StagingPath | |
| Local directory used to build converted files before they are copied | |
| back over the originals. Should sit on fast local storage, not on the | |
| mount being processed. Created if it does not exist. | |
| Default: a 'remux-staging' folder under the user temp directory. | |
| Alias: t | |
| .PARAMETER FFmpegPath | |
| Path to the ffmpeg executable, or a bare command name to resolve | |
| through PATH. | |
| Default: 'ffmpeg'. | |
| Alias: m | |
| .PARAMETER FFprobePath | |
| Path to the ffprobe executable, or a bare command name to resolve | |
| through PATH. | |
| Default: 'ffprobe'. | |
| Alias: r | |
| .PARAMETER Quiet | |
| Suppress informational chatter (Info, Success, Debug). Warnings and | |
| errors still emit. | |
| Alias: q | |
| .PARAMETER Silent | |
| Suppress all log output including warnings. Genuine errors still reach | |
| the error stream. | |
| .PARAMETER Help | |
| Print this help text to the terminal. | |
| Alias: h | |
| .EXAMPLE | |
| .\Convert-MediaContainer.ps1 -Path 'D:\Media\Lectures' -WhatIf | |
| Audits the tree and reports the container and codecs of every .mp4 | |
| without converting or writing anything. Run this first. | |
| .EXAMPLE | |
| .\Convert-MediaContainer.ps1 -Path 'D:\Media\Lectures' | |
| Converts every non-MP4 container under the tree, prompting before each | |
| replacement because ConfirmImpact is High. | |
| .EXAMPLE | |
| .\Convert-MediaContainer.ps1 -Path 'D:\Media\Lectures\Series One' -Confirm:$false | |
| Converts a single subfolder without prompting. Use once a -WhatIf pass | |
| has confirmed the scope. | |
| .EXAMPLE | |
| .\Convert-MediaContainer.ps1 -Path 'D:\Media\Lectures' -StagingPath 'E:\staging' -Quiet | |
| Stages conversions on a specific local volume and suppresses | |
| informational output, leaving only warnings and errors. | |
| .NOTES | |
| External tools are launched through System.Diagnostics.ProcessStartInfo | |
| rather than Start-Process. ProcessStartInfo.ArgumentList applies native | |
| per-argument escaping, so paths containing spaces arrive intact, and it | |
| carries no ShouldProcess semantics that would inherit -WhatIf and | |
| suppress a read-only probe. The destructive gate sits around the | |
| remux-and-replace unit, which is the only state-changing act. | |
| Stream mapping is pinned to the first video and first audio stream | |
| (-map 0:v:0 -map 0:a:0). MPEG-TS files frequently expose program | |
| wrappers that duplicate stream entries, and pinning the mapping keeps | |
| the output deterministic. Subtitle and secondary audio tracks are not | |
| carried over. | |
| The audio bitstream filter aac_adtstoasc converts the ADTS framing that | |
| transport streams use into the ASC form MP4 expects. Recent ffmpeg | |
| builds usually insert it automatically; declaring it explicitly makes | |
| the failure loud rather than silent. | |
| If the target sits on object storage with versioning enabled, the | |
| overwrite creates a new version and the original remains recoverable | |
| server-side. Verify that versioning is actually on before relying on it | |
| as the rollback path. | |
| #> | |
| [CmdletBinding(SupportsShouldProcess=$true,ConfirmImpact='High',DefaultParameterSetName='Default')] | |
| Param( | |
| [Parameter(Mandatory=$true,ParameterSetName='Default')] | |
| [Alias("p")] | |
| [string]$Path, | |
| [Parameter(Mandatory=$false,ParameterSetName='Default')] | |
| [Alias("f")] | |
| [string]$Pattern = '*.mp4', | |
| [Parameter(Mandatory=$false,ParameterSetName='Default')] | |
| [Alias("t")] | |
| [string]$StagingPath = ([System.IO.Path]::Combine($env:TEMP, 'remux-staging')), | |
| [Parameter(Mandatory=$false,ParameterSetName='Default')] | |
| [Alias("m")] | |
| [string]$FFmpegPath = 'ffmpeg', | |
| [Parameter(Mandatory=$false,ParameterSetName='Default')] | |
| [Alias("r")] | |
| [string]$FFprobePath = 'ffprobe', | |
| [Parameter(Mandatory=$false,ParameterSetName='Default')] | |
| [Alias("q")] | |
| [Switch]$Quiet, | |
| [Parameter(Mandatory=$false,ParameterSetName='Default')] | |
| [Switch]$Silent, | |
| [Parameter(Mandatory=$true,ParameterSetName='HelpText')] | |
| [Alias("h")] | |
| [Switch]$Help | |
| ) | |
| #_______________________________________________________________________________ | |
| ## Declare Functions | |
| function Write-Log { | |
| [CmdletBinding()] | |
| Param( | |
| [Parameter(Mandatory=$true,Position=0)] | |
| [string]$Message, | |
| [Parameter(Mandatory=$false)] | |
| [ValidateSet('Info','Success','Warn','Error','Debug')] | |
| [string]$Level = 'Info', | |
| [Parameter(Mandatory=$false)] | |
| [string]$Source = $null | |
| ) | |
| if ($script:LogSilent -and $Level -ne 'Error') { return } | |
| if ($script:LogQuiet -and (@('Info','Success','Debug') -contains $Level)) { return } | |
| $stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss.fff') | |
| $tag = if ($Source) { "[$Source] " } else { '' } | |
| $label = $Level.ToUpper().PadRight(7) | |
| $color = switch ($Level) { | |
| 'Info' { 'Gray' } | |
| 'Success' { 'Green' } | |
| 'Warn' { 'Yellow' } | |
| 'Error' { 'Red' } | |
| 'Debug' { 'DarkGray' } | |
| } | |
| Write-Host ("{0} {1}{2} {3}" -f $stamp, $tag, $label, $Message) -ForegroundColor $color | |
| } | |
| function Resolve-ToolPath { | |
| [CmdletBinding()] | |
| Param( | |
| [Parameter(Mandatory=$true,Position=0)] | |
| [string]$Candidate | |
| ) | |
| # An explicit path wins; otherwise resolve the bare name via PATH. | |
| if ([System.IO.File]::Exists($Candidate)) { | |
| return [System.IO.Path]::GetFullPath($Candidate) | |
| } | |
| $found = Get-Command -Name $Candidate -CommandType Application -ErrorAction SilentlyContinue | | |
| Select-Object -First 1 | |
| if ($found) { | |
| return $found.Source | |
| } | |
| return $null | |
| } | |
| function Invoke-Tool { | |
| [CmdletBinding()] | |
| Param( | |
| [Parameter(Mandatory=$true,Position=0)] | |
| [string]$FilePath, | |
| [Parameter(Mandatory=$true,Position=1)] | |
| [string[]]$Arguments | |
| ) | |
| # Start-Process is deliberately avoided here. Its -ArgumentList | |
| # joins array elements with spaces and does not quote elements that | |
| # themselves contain spaces, so any path with a space arrives at the | |
| # tool split into several arguments. ProcessStartInfo.ArgumentList | |
| # applies the platform's native argument escaping instead, and it | |
| # carries no ShouldProcess semantics to inherit -WhatIf from. | |
| $psi = [System.Diagnostics.ProcessStartInfo]::new() | |
| $psi.FileName = $FilePath | |
| $psi.UseShellExecute = $false | |
| $psi.CreateNoWindow = $true | |
| $psi.RedirectStandardOutput = $true | |
| $psi.RedirectStandardError = $true | |
| foreach ($argument in $Arguments) { | |
| $psi.ArgumentList.Add($argument) | |
| } | |
| $proc = [System.Diagnostics.Process]::Start($psi) | |
| # Read both streams before waiting. A tool that fills the stderr | |
| # pipe buffer while the parent blocks on WaitForExit deadlocks. | |
| $outTask = $proc.StandardOutput.ReadToEndAsync() | |
| $errTask = $proc.StandardError.ReadToEndAsync() | |
| $proc.WaitForExit() | |
| $stdOut = $outTask.GetAwaiter().GetResult() | |
| $stdErr = $errTask.GetAwaiter().GetResult() | |
| $composed = ($Arguments | ForEach-Object { | |
| if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } | |
| }) -join ' ' | |
| return [PSCustomObject]@{ | |
| ExitCode = $proc.ExitCode | |
| StdOut = $stdOut | |
| StdErr = $stdErr | |
| Command = ("{0} {1}" -f $FilePath, $composed) | |
| } | |
| } | |
| function Get-MediaProbe { | |
| [CmdletBinding()] | |
| Param( | |
| [Parameter(Mandatory=$true,Position=0)] | |
| [string]$MediaPath, | |
| [Parameter(Mandatory=$true)] | |
| [string]$ProbeExe | |
| ) | |
| # Always returns an object. Ok reports whether the probe succeeded, | |
| # and Error carries the reason so the caller can report it rather | |
| # than swallowing the failure behind a bare null. | |
| $probeArgs = @( | |
| '-v', 'error', | |
| '-show_entries', 'format=format_name,duration', | |
| '-show_entries', 'stream=codec_type,codec_name', | |
| '-of', 'json', | |
| $MediaPath | |
| ) | |
| $failure = [PSCustomObject]@{ | |
| Ok = $false | |
| Error = '' | |
| FormatName = '' | |
| Duration = 0.0 | |
| VideoCodec = '' | |
| AudioCodec = '' | |
| } | |
| try { | |
| $result = Invoke-Tool -FilePath $ProbeExe -Arguments $probeArgs | |
| } catch { | |
| $failure.Error = ("could not launch ffprobe: {0}" -f $_.Exception.Message) | |
| return $failure | |
| } | |
| if ($result.ExitCode -ne 0) { | |
| $failure.Error = ("ffprobe exit {0}: {1}" -f $result.ExitCode, $result.StdErr.Trim()) | |
| return $failure | |
| } | |
| if ([string]::IsNullOrWhiteSpace($result.StdOut)) { | |
| $failure.Error = 'ffprobe returned no output' | |
| return $failure | |
| } | |
| try { | |
| $json = $result.StdOut | ConvertFrom-Json | |
| } catch { | |
| $failure.Error = ("could not parse ffprobe JSON: {0}" -f $_.Exception.Message) | |
| return $failure | |
| } | |
| $videoCodec = ($json.streams | Where-Object { $_.codec_type -eq 'video' } | | |
| Select-Object -First 1).codec_name | |
| $audioCodec = ($json.streams | Where-Object { $_.codec_type -eq 'audio' } | | |
| Select-Object -First 1).codec_name | |
| $duration = 0.0 | |
| if ($json.format.duration) { | |
| [double]::TryParse($json.format.duration, [ref]$duration) | Out-Null | |
| } | |
| return [PSCustomObject]@{ | |
| Ok = $true | |
| Error = '' | |
| FormatName = [string]$json.format.format_name | |
| Duration = $duration | |
| VideoCodec = [string]$videoCodec | |
| AudioCodec = [string]$audioCodec | |
| } | |
| } | |
| #_______________________________________________________________________________ | |
| ## Declare Variables and Arrays | |
| $script:LogQuiet = $false | |
| $script:LogSilent = $false | |
| $ThisScriptPath = $MyInvocation.MyCommand.Path | |
| # ffprobe reports real MP4 files under this multiplexed demuxer name. | |
| $Mp4FormatMarker = 'mp4' | |
| # Stream copy only produces a browser-playable MP4 for these codecs. | |
| $SupportedVideoCodecs = @('h264') | |
| $SupportedAudioCodecs = @('aac') | |
| # Duration drift tolerated between source and remuxed output, in | |
| # seconds, or one percent of the source duration if that is larger. | |
| $DurationToleranceSeconds = 2.0 | |
| #_______________________________________________________________________________ | |
| ## Execute Operations | |
| # Catch help text requests | |
| if (($Help) -or ($PSCmdlet.ParameterSetName -eq 'HelpText')) { | |
| Get-Help $ThisScriptPath -Detailed | |
| exit 0 | |
| } | |
| # Wire suppression flags to the logging state | |
| if ($Quiet) { $script:LogQuiet = $true } | |
| if ($Silent) { $script:LogSilent = $true } | |
| # Environment precondition: the target directory must exist | |
| if (-not (Test-Path -LiteralPath $Path -PathType Container)) { | |
| Write-Host ("FAIL: target directory not found: {0}" -f $Path) -ForegroundColor Red | |
| exit 2 | |
| } | |
| # Environment precondition: both tools must resolve | |
| $ffmpegExe = Resolve-ToolPath $FFmpegPath | |
| $ffprobeExe = Resolve-ToolPath $FFprobePath | |
| if (-not $ffmpegExe) { | |
| Write-Host ("FAIL: ffmpeg not found ('{0}'). Install a native Windows build and add it to PATH, or pass -FFmpegPath." -f $FFmpegPath) -ForegroundColor Red | |
| exit 2 | |
| } | |
| if (-not $ffprobeExe) { | |
| Write-Host ("FAIL: ffprobe not found ('{0}'). Install a native Windows build and add it to PATH, or pass -FFprobePath." -f $FFprobePath) -ForegroundColor Red | |
| exit 2 | |
| } | |
| # Environment precondition: staging directory must be usable | |
| try { | |
| [System.IO.Directory]::CreateDirectory($StagingPath) | Out-Null | |
| } catch { | |
| Write-Host ("FAIL: cannot create staging directory {0}: {1}" -f $StagingPath, $_.Exception.Message) -ForegroundColor Red | |
| exit 2 | |
| } | |
| # Resolve the containment boundary once, with a trailing separator so a | |
| # sibling directory sharing a name prefix cannot pass the check. | |
| $rootFull = [System.IO.Path]::GetFullPath((Resolve-Path -LiteralPath $Path).Path) | |
| $rootPrefix = $rootFull.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + | |
| [System.IO.Path]::DirectorySeparatorChar | |
| Write-Log ("Root: {0}" -f $rootFull) -Level Info -Source 'Scan' | |
| Write-Log ("Staging: {0}" -f $StagingPath) -Level Info -Source 'Scan' | |
| Write-Log ("ffmpeg: {0}" -f $ffmpegExe) -Level Debug -Source 'Scan' | |
| Write-Log ("ffprobe: {0}" -f $ffprobeExe) -Level Debug -Source 'Scan' | |
| # Prove the tool actually runs and that argument quoting survives before | |
| # walking the tree. A failure here is an environment problem, not a | |
| # per-file one, and reporting it once beats reporting it per file. | |
| $toolCheck = Invoke-Tool -FilePath $ffprobeExe -Arguments @('-version') | |
| if ($toolCheck.ExitCode -ne 0) { | |
| Write-Host ("FAIL: ffprobe is present but did not run cleanly (exit {0}): {1}" -f $toolCheck.ExitCode, $toolCheck.StdErr.Trim()) -ForegroundColor Red | |
| exit 2 | |
| } | |
| $candidates = Get-ChildItem -LiteralPath $rootFull -Filter $Pattern -File -Recurse | |
| if (-not $candidates) { | |
| Write-Log ("No files matching '{0}' found under the target tree." -f $Pattern) -Level Success -Source 'Scan' | |
| exit 0 | |
| } | |
| Write-Log ("Found {0} candidate file(s)." -f $candidates.Count) -Level Info -Source 'Scan' | |
| $compliant = 0 | |
| $converted = 0 | |
| $skipped = 0 | |
| $failed = 0 | |
| $wouldConvert = 0 | |
| foreach ($file in $candidates) { | |
| $sourcePath = $file.FullName | |
| # Containment guard: never act on anything outside the root tree. | |
| $fileFull = [System.IO.Path]::GetFullPath($sourcePath) | |
| if (-not $fileFull.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { | |
| Write-Log ("Outside target tree, skipping: {0}" -f $fileFull) -Level Warn -Source 'Guard' | |
| $skipped++ | |
| continue | |
| } | |
| # Reparse points can redirect outside the tree even when the path | |
| # looks contained. Leave them alone. | |
| if ($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { | |
| Write-Log ("Reparse point, skipping: {0}" -f $fileFull) -Level Warn -Source 'Guard' | |
| $skipped++ | |
| continue | |
| } | |
| $probe = Get-MediaProbe -MediaPath $fileFull -ProbeExe $ffprobeExe | |
| if (-not $probe.Ok) { | |
| Write-Log ("Probe failed ({0}), skipping: {1}" -f $probe.Error, $fileFull) -Level Error -Source 'Probe' | |
| $failed++ | |
| continue | |
| } | |
| # Already an MP4 container: nothing to do. | |
| if ($probe.FormatName -like ("*{0}*" -f $Mp4FormatMarker)) { | |
| Write-Log ("Compliant ({0}): {1}" -f $probe.FormatName, $file.Name) -Level Debug -Source 'Probe' | |
| $compliant++ | |
| continue | |
| } | |
| # Stream copy cannot rescue codecs the browser will not decode. | |
| if (($SupportedVideoCodecs -notcontains $probe.VideoCodec) -or | |
| ($SupportedAudioCodecs -notcontains $probe.AudioCodec)) { | |
| Write-Log ("Unsupported codecs (video={0}, audio={1}), needs transcode not remux: {2}" -f $probe.VideoCodec, $probe.AudioCodec, $file.Name) -Level Warn -Source 'Probe' | |
| $skipped++ | |
| continue | |
| } | |
| Write-Log ("Needs remux ({0}, {1}/{2}): {3}" -f $probe.FormatName, $probe.VideoCodec, $probe.AudioCodec, $file.Name) -Level Info -Source 'Probe' | |
| if (-not $PSCmdlet.ShouldProcess($fileFull, 'Remux container to faststart MP4 and overwrite in place')) { | |
| $wouldConvert++ | |
| continue | |
| } | |
| $stagedPath = [System.IO.Path]::Combine($StagingPath, ("remux-{0}.mp4" -f ([System.Guid]::NewGuid().ToString('N')))) | |
| try { | |
| $ffmpegArgs = @( | |
| '-hide_banner', | |
| '-nostdin', | |
| '-loglevel', 'error', | |
| '-y', | |
| '-i', $fileFull, | |
| '-map', '0:v:0', | |
| '-map', '0:a:0', | |
| '-c', 'copy', | |
| '-bsf:a', 'aac_adtstoasc', | |
| '-movflags', '+faststart', | |
| '-f', 'mp4', | |
| $stagedPath | |
| ) | |
| $run = Invoke-Tool -FilePath $ffmpegExe -Arguments $ffmpegArgs | |
| if ($run.ExitCode -ne 0) { | |
| Write-Log ("ffmpeg exited {0} for {1}: {2}" -f $run.ExitCode, $file.Name, $run.StdErr.Trim()) -Level Error -Source 'Remux' | |
| $failed++ | |
| continue | |
| } | |
| if (-not [System.IO.File]::Exists($stagedPath)) { | |
| Write-Log ("ffmpeg reported success but produced no output: {0}" -f $file.Name) -Level Error -Source 'Remux' | |
| $failed++ | |
| continue | |
| } | |
| # Verify the output before it is allowed to replace the source. | |
| $verify = Get-MediaProbe -MediaPath $stagedPath -ProbeExe $ffprobeExe | |
| if (-not $verify.Ok) { | |
| Write-Log ("Output failed to probe ({0}), original left untouched: {1}" -f $verify.Error, $file.Name) -Level Error -Source 'Verify' | |
| $failed++ | |
| continue | |
| } | |
| if ($verify.FormatName -notlike ("*{0}*" -f $Mp4FormatMarker)) { | |
| Write-Log ("Output container is still '{0}', original left untouched: {1}" -f $verify.FormatName, $file.Name) -Level Error -Source 'Verify' | |
| $failed++ | |
| continue | |
| } | |
| $tolerance = [Math]::Max($DurationToleranceSeconds, ($probe.Duration * 0.01)) | |
| $drift = [Math]::Abs($verify.Duration - $probe.Duration) | |
| if ($drift -gt $tolerance) { | |
| Write-Log ("Duration drift {0:N2}s exceeds tolerance {1:N2}s, original left untouched: {2}" -f $drift, $tolerance, $file.Name) -Level Error -Source 'Verify' | |
| $failed++ | |
| continue | |
| } | |
| # Replace the original. On a versioned object store this creates | |
| # a new version rather than destroying the previous bytes. The | |
| # outer ShouldProcess gate already authorized this, so the copy | |
| # does not prompt a second time. | |
| Copy-Item -LiteralPath $stagedPath -Destination $fileFull -Force -Confirm:$false | |
| $converted++ | |
| Write-Log ("Converted ({0:N2}s, drift {1:N2}s): {2}" -f $verify.Duration, $drift, $file.Name) -Level Success -Source 'Remux' | |
| } catch { | |
| $failed++ | |
| Write-Log ("Unhandled failure on {0}: {1}" -f $file.Name, $_.Exception.Message) -Level Error -Source 'Remux' | |
| } finally { | |
| if ([System.IO.File]::Exists($stagedPath)) { | |
| Remove-Item -LiteralPath $stagedPath -Force ` | |
| -WhatIf:$false -Confirm:$false -ErrorAction SilentlyContinue | |
| } | |
| } | |
| } | |
| Write-Log ("Done. Already compliant {0}, converted {1}, skipped {2}, failed {3}." -f $compliant, $converted, $skipped, $failed) -Level Info -Source 'Summary' | |
| if ($wouldConvert -gt 0) { | |
| Write-Log ("{0} file(s) would be converted on a real run." -f $wouldConvert) -Level Info -Source 'Summary' | |
| } | |
| if ($failed -gt 0) { | |
| exit 1 | |
| } | |
| exit 0 | |
| #_______________________________________________________________________________ | |
| ## End of script |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment