Last active
September 15, 2026 16:17
-
-
Save brianly/c0efeb705b951b43c6a8af010a1d76f1 to your computer and use it in GitHub Desktop.
Compares the size of files before and after encoding across the input and output directories specified. Use it to identify files that Handbrake or ffmeg failed to encode to a smaller file size. This tool is most useful when you have an arbitrary list of input files with different formats and dimensions and aren't choosing settings appropriate fo…
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 | |
| Compares files in a source directory against files in a target directory, | |
| matching them by the source file's base name (the part before the first | |
| period), even when the target file name has an extra suffix like | |
| "_720p" or "-1080p". | |
| .DESCRIPTION | |
| For every file in the source directory, the script looks for files in the | |
| target directory whose name starts with the source file's base name | |
| followed by a separator (underscore, hyphen, period, or space) or nothing | |
| at all. This handles cases like: | |
| Source: myfile.mp4 | |
| Target: myfile_720p.mp4 | |
| Target: myfile-1080p.mp4 | |
| If more than one target file matches a single source file, each match is | |
| numbered (1st, 2nd, 3rd, ...) in the output. | |
| For every match, the script reports the file size of both files and the | |
| percentage difference (target vs. source), rounded to the nearest whole | |
| number, with a +/- sign to indicate whether the target is larger or | |
| smaller than the source. | |
| .PARAMETER SourceDirectory | |
| Path to the source directory (e.g. contains "myfile.mp4"). | |
| .PARAMETER TargetDirectory | |
| Path to the target directory (e.g. contains "myfile_720p.mp4"). | |
| .PARAMETER PassThru | |
| Instead of printing a formatted table, emit the raw result objects to | |
| the pipeline so they can be sorted, filtered, or exported (e.g. to CSV). | |
| .EXAMPLE | |
| .\Compare-Directories.ps1 -SourceDirectory "C:\Originals" -TargetDirectory "C:\Encodes" | |
| .EXAMPLE | |
| .\Compare-Directories.ps1 "C:\Originals" "C:\Encodes" | |
| .EXAMPLE | |
| .\Compare-Directories.ps1 "C:\Originals" "C:\Encodes" -PassThru | | |
| Export-Csv "comparison.csv" -NoTypeInformation | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory = $true, Position = 0)] | |
| [AllowEmptyString()] | |
| [string]$SourceDirectory, | |
| [Parameter(Mandatory = $true, Position = 1)] | |
| [AllowEmptyString()] | |
| [string]$TargetDirectory, | |
| [Parameter()] | |
| [switch]$PassThru | |
| ) | |
| # --- Input validation --- | |
| # Handled manually (rather than with [ValidateScript]) so we can give clear, | |
| # specific error messages instead of a generic parameter-binding exception. | |
| function Test-DirectoryArgument { | |
| param( | |
| [string]$PathValue, | |
| [string]$ArgumentName | |
| ) | |
| if ([string]::IsNullOrWhiteSpace($PathValue)) { | |
| Write-Error "$ArgumentName is empty. Please supply a directory path." | |
| return $null | |
| } | |
| if (-not (Test-Path -LiteralPath $PathValue)) { | |
| Write-Error "$ArgumentName does not exist: '$PathValue'" | |
| return $null | |
| } | |
| $item = Get-Item -LiteralPath $PathValue | |
| if (-not $item.PSIsContainer) { | |
| Write-Error "$ArgumentName is not a directory (it looks like a file): '$PathValue'" | |
| return $null | |
| } | |
| return (Resolve-Path -LiteralPath $PathValue).Path | |
| } | |
| $resolvedSource = Test-DirectoryArgument -PathValue $SourceDirectory -ArgumentName "SourceDirectory" | |
| $resolvedTarget = Test-DirectoryArgument -PathValue $TargetDirectory -ArgumentName "TargetDirectory" | |
| if (-not $resolvedSource -or -not $resolvedTarget) { | |
| exit 1 | |
| } | |
| $SourceDirectory = $resolvedSource | |
| $TargetDirectory = $resolvedTarget | |
| if ($SourceDirectory -eq $TargetDirectory) { | |
| Write-Error "SourceDirectory and TargetDirectory resolve to the same folder: '$SourceDirectory'" | |
| exit 1 | |
| } | |
| function Get-BaseName { | |
| <# | |
| Returns everything before the FIRST period in the file name, so that | |
| "myfile.backup.mp4" still yields "myfile" (matches the requirement | |
| that the part before the period in the source name is reliable). | |
| #> | |
| param([string]$FileName) | |
| $dotIndex = $FileName.IndexOf('.') | |
| if ($dotIndex -ge 0) { | |
| return $FileName.Substring(0, $dotIndex) | |
| } | |
| return $FileName | |
| } | |
| function Format-Size { | |
| param([long]$Bytes) | |
| if ($Bytes -ge 1GB) { return "{0:N2} GB" -f ($Bytes / 1GB) } | |
| if ($Bytes -ge 1MB) { return "{0:N2} MB" -f ($Bytes / 1MB) } | |
| if ($Bytes -ge 1KB) { return "{0:N2} KB" -f ($Bytes / 1KB) } | |
| return "$Bytes bytes" | |
| } | |
| function Get-MatchRank { | |
| <# | |
| Returns a compact ordinal string like "1st of 2" so the reader can | |
| see both which match this is and how many total matches the source | |
| file had, without needing a separate lookup. | |
| #> | |
| param( | |
| [int]$Number, | |
| [int]$Total | |
| ) | |
| $suffix = switch ($Number % 100) { | |
| { $_ -in 11..13 } { "th"; break } | |
| default { | |
| switch ($Number % 10) { | |
| 1 { "st" } | |
| 2 { "nd" } | |
| 3 { "rd" } | |
| default { "th" } | |
| } | |
| } | |
| } | |
| return "$Number$suffix of $Total" | |
| } | |
| # Gather files once up front. @() ensures a single-item result from | |
| # Get-ChildItem is still treated as an array so .Count works reliably. | |
| $sourceFiles = @(Get-ChildItem -LiteralPath $SourceDirectory -File) | |
| $targetFiles = @(Get-ChildItem -LiteralPath $TargetDirectory -File) | |
| if ($sourceFiles.Count -eq 0) { | |
| Write-Warning "No files found in source directory: $SourceDirectory" | |
| exit 0 | |
| } | |
| if ($targetFiles.Count -eq 0) { | |
| Write-Warning "No files found in target directory: $TargetDirectory" | |
| exit 0 | |
| } | |
| $results = New-Object System.Collections.Generic.List[object] | |
| $unmatchedSource = New-Object System.Collections.Generic.List[object] | |
| foreach ($sourceFile in $sourceFiles) { | |
| $baseName = Get-BaseName -FileName $sourceFile.Name | |
| $escapedBase = [regex]::Escape($baseName) | |
| # Match target files whose name starts with the base name, provided the | |
| # base name isn't immediately glued to another letter/digit. That still | |
| # rejects "myfile2.mp4" (an unrelated file) while accepting virtually | |
| # any separator or descriptive text right after the base name, e.g.: | |
| # myfile_720p.mp4 | |
| # myfile-1080p.mp4 | |
| # myfile there are spaces_720p.mp4 | |
| # myfile, remastered (2024).mp4 | |
| $pattern = "^$escapedBase(?![a-zA-Z0-9])" | |
| $matchingFiles = @($targetFiles | Where-Object { $_.Name -match $pattern } | | |
| Sort-Object Name) | |
| if ($matchingFiles.Count -eq 0) { | |
| $unmatchedSource.Add($sourceFile.Name) | Out-Null | |
| continue | |
| } | |
| $matchIndex = 0 | |
| foreach ($targetFile in $matchingFiles) { | |
| $matchIndex++ | |
| $sourceSize = $sourceFile.Length | |
| $targetSize = $targetFile.Length | |
| if ($sourceSize -eq 0) { | |
| $percentText = "N/A" | |
| } | |
| else { | |
| $percentValue = [math]::Round((($targetSize - $sourceSize) / $sourceSize) * 100) | |
| $sign = if ($percentValue -gt 0) { "+" } else { "" } | |
| $percentText = "$sign$percentValue%" | |
| } | |
| $resultObject = [PSCustomObject]@{ | |
| PSTypeName = 'FileCompare.MatchResult' | |
| SourceFile = $sourceFile.Name | |
| Match = Get-MatchRank -Number $matchIndex -Total $matchingFiles.Count | |
| TargetFile = $targetFile.Name | |
| SourceSize = Format-Size $sourceSize | |
| TargetSize = Format-Size $targetSize | |
| Diff = $percentText | |
| } | |
| $results.Add($resultObject) | Out-Null | |
| } | |
| } | |
| # --- Output --- | |
| # By default, results are displayed as a formatted table (Format-Table), | |
| # which is the standard, readable way PowerShell presents tabular data. | |
| # | |
| # Pass -PassThru to instead receive the raw result objects on the pipeline, | |
| # so they can be sorted, filtered, or exported, e.g.: | |
| # .\Compare-Directories.ps1 .\src .\dst -PassThru | Export-Csv results.csv -NoTypeInformation | |
| # .\Compare-Directories.ps1 .\src .\dst -PassThru | Where-Object { $_.Diff -like '-*' } | |
| # (Format-Table's output is display-only and isn't suitable for piping, so | |
| # the two modes are mutually exclusive.) | |
| Write-Host "" | |
| Write-Host "Comparing '$SourceDirectory' -> '$TargetDirectory'" | |
| Write-Host "" | |
| if ($PassThru) { | |
| $results | |
| } | |
| elseif ($results.Count -eq 0) { | |
| Write-Warning "No matches found." | |
| } | |
| else { | |
| # Piped through Out-String -Width so the table renders correctly even | |
| # when there's no real console attached (redirected output, scheduled | |
| # tasks, CI, etc.), where Format-Table -AutoSize alone can otherwise | |
| # produce no visible output. | |
| $results | | |
| Format-Table -Property SourceFile, Match, TargetFile, SourceSize, TargetSize, Diff -AutoSize | | |
| Out-String -Width 200 | | |
| Write-Host | |
| } | |
| if ($unmatchedSource.Count -gt 0 -and -not $PassThru) { | |
| Write-Host "Source files with no match in target directory:" | |
| $unmatchedSource | | |
| ForEach-Object { [PSCustomObject]@{ SourceFile = $_ } } | | |
| Format-Table -Property SourceFile -AutoSize | | |
| Out-String -Width 200 | | |
| Write-Host | |
| } | |
| if (-not $PassThru) { | |
| Write-Host "Summary: $($results.Count) match(es) found across $($sourceFiles.Count) source file(s) ($($unmatchedSource.Count) unmatched)." | |
| Write-Host "" | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment