<# .SYNOPSIS Keeps one copy of each game in a folder of backups and moves the other versions aside. .DESCRIPTION Reads file names in the usual "Title (Region) (Rev x) [flags]" style (No-Intro, Redump and most GoodTools sets), groups the versions of the same game, and picks one to keep: 1. Clean dumps before bad dumps, betas, protos, demos and anything tagged with -Deprioritize 2. Your preferred region, in the order you give with -PreferRegion 3. The highest revision (Rev 2 beats Rev 1 beats no revision; v1.1 beats v1.0) 4. Verified [!] dumps, then the name with the fewest extra tags Multi-disc sets (Disc 1, Disc 2) and translations stay in their own groups, so they're never treated as duplicates of each other. The extras are moved to a _duplicates folder inside each folder unless you ask for -Delete. Supports -WhatIf. .PARAMETER Path One or more folders of game files. Defaults to the current folder. .PARAMETER Extension File extensions to consider. Default: every file. .PARAMETER Recurse Also process subfolders. Each folder is deduplicated on its own. .PARAMETER PreferRegion Regions in order of preference. Default: USA, World, Europe, Japan. .PARAMETER Deprioritize Tags that make a version lose to any clean release. Default: Beta, Proto, Demo, Sample, Pirate, Hack. .PARAMETER DuplicateFolder Name of the folder the extras are moved into, created inside each folder. Default: _duplicates. .PARAMETER Delete Delete the extras instead of moving them. Honors -WhatIf and -Confirm. .EXAMPLE .\Remove-DuplicateRom.ps1 -Path D:\Games\snes -WhatIf .EXAMPLE .\Remove-DuplicateRom.ps1 -Path D:\Games -Recurse -PreferRegion Europe, World, USA | Export-Csv dedupe.csv -NoTypeInformation #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('FullName')] [string[]]$Path = '.', [string[]]$Extension, [switch]$Recurse, [ValidateNotNullOrEmpty()][string[]]$PreferRegion = @('USA', 'World', 'Europe', 'Japan'), [string[]]$Deprioritize = @('Beta', 'Proto', 'Demo', 'Sample', 'Pirate', 'Hack'), [ValidatePattern('^[^\\/:*?"<>|]+$')][string]$DuplicateFolder = '_duplicates', [switch]$Delete ) begin { # GoodTools-style one-letter codes and a few common short forms, mapped to No-Intro region names. $regionAlias = @{ 'U' = 'USA'; 'US' = 'USA'; 'E' = 'Europe'; 'EU' = 'Europe'; 'J' = 'Japan'; 'JP' = 'Japan'; 'W' = 'World'; 'WW' = 'World' 'UE' = 'USA,Europe'; 'JU' = 'Japan,USA'; 'JUE' = 'World'; 'A' = 'Australia'; 'B' = 'Brazil'; 'K' = 'Korea'; 'F' = 'France'; 'G' = 'Germany' } $knownRegions = 'USA', 'Europe', 'Japan', 'World', 'Asia', 'Australia', 'Brazil', 'Canada', 'China', 'France', 'Germany', 'Hong Kong', 'Italy', 'Korea', 'Netherlands', 'Spain', 'Sweden', 'Taiwan', 'UK', 'Russia', 'Scandinavia', 'Latin America' $extFilter = @($Extension | Where-Object { $_ } | ForEach-Object { if ($_ -like '.*') { $_.ToLower() } else { ".$_".ToLower() } }) function Get-RomInfo([IO.FileInfo]$File) { $base = [IO.Path]::GetFileNameWithoutExtension($File.Name) $tags = @([regex]::Matches($base, '\(([^\)]+)\)') | ForEach-Object { $_.Groups[1].Value.Trim() }) $flags = @([regex]::Matches($base, '\[([^\]]+)\]') | ForEach-Object { $_.Groups[1].Value.Trim() }) $title = ($base -replace '\s*[\(\[].*$', '').Trim() if (-not $title) { $title = $base } $regions = [System.Collections.Generic.List[string]]::new() $revision = [version]'0.0' $verified = $false; $penalty = 0; $groupExtra = @() # Round-bracket tags: region, revision, disc number, beta/proto and so on. foreach ($tag in $tags) { $parts = $tag -split '\s*,\s*' if ($regionAlias.ContainsKey($tag)) { $regionAlias[$tag] -split ',' | ForEach-Object { $regions.Add($_) } } elseif (@($parts | Where-Object { $knownRegions -contains $_ }).Count -eq $parts.Count) { $parts | ForEach-Object { $regions.Add($_) } } elseif ($tag -match '^Rev\s*([0-9]+|[A-Z])$') { $n = $Matches[1] $revision = [version]::new($(if ($n -match '^\d+$') { [int]$n } else { [int][char]$n.ToUpper() - 64 }), 0) } elseif ($tag -match '^v(\d+(\.\d+){1,3})$') { $revision = [version]$Matches[1] } elseif ($tag -match '^(Disc|Disk|Side|Tape)\s*\w+' -or $tag -match 'Translat') { $groupExtra += $tag } else { foreach ($word in $Deprioritize) { if ($tag -match "^$([regex]::Escape($word))\b") { $penalty = 1 } } } } # Square-bracket dump flags: [!] verified, [b] bad, [o] overdump, [h] hack, [p] pirate, [T+Eng] translation. foreach ($flag in $flags) { if ($flag -eq '!') { $verified = $true } elseif ($flag -cmatch '^[bohp]\d*') { $penalty = 1 } elseif ($flag -cmatch '^T[+-]') { $groupExtra += $flag } } $rank = $PreferRegion.Count for ($i = 0; $i -lt $rank; $i++) { if ($regions -contains $PreferRegion[$i]) { $rank = $i } } # Compare titles loosely so "Title - Subtitle" and "Title_ Subtitle" still match. $key = (($title.ToLowerInvariant() -replace '[^a-z0-9]+', ' ').Trim()) + '|' + (($groupExtra | ForEach-Object { $_.ToLowerInvariant() }) -join '|') [pscustomobject]@{ File = $File; Key = $key; Title = $title; Region = ($regions | Select-Object -Unique) -join ', ' RegionRank = $rank; Revision = $revision; Verified = $verified; Penalty = $penalty; TagCount = $tags.Count + $flags.Count } } } process { foreach ($root in $Path) { if (-not (Test-Path -LiteralPath $root -PathType Container)) { Write-Warning "Not a folder: $root"; continue } $folders = @(Get-Item -LiteralPath $root) if ($Recurse) { $folders += Get-ChildItem -LiteralPath $root -Directory -Recurse | Where-Object { $_.Name -ne $DuplicateFolder -and $_.FullName -notlike "*\$DuplicateFolder\*" -and $_.FullName -notlike "*/$DuplicateFolder/*" } } foreach ($folder in $folders) { $files = Get-ChildItem -LiteralPath $folder.FullName -File | Where-Object { -not $extFilter -or $extFilter -contains $_.Extension.ToLower() } $groups = $files | ForEach-Object { Get-RomInfo $_ } | Group-Object Key | Where-Object Count -gt 1 foreach ($group in $groups) { $ranked = @($group.Group | Sort-Object Penalty, RegionRank, @{ Expression = 'Revision'; Descending = $true }, @{ Expression = 'Verified'; Descending = $true }, TagCount, { $_.File.Name }) $keep = $ranked[0] [pscustomobject]@{ Folder = $folder.FullName; Title = $keep.Title; Action = 'Keep'; Name = $keep.File.Name; Region = $keep.Region; Revision = $keep.Revision; Destination = $null; Status = 'Kept' } foreach ($extra in $ranked | Select-Object -Skip 1) { $result = [ordered]@{ Folder = $folder.FullName; Title = $keep.Title; Action = $(if ($Delete) { 'Delete' } else { 'Move' }); Name = $extra.File.Name; Region = $extra.Region; Revision = $extra.Revision; Destination = $null; Status = 'WhatIf' } try { if ($Delete) { if ($PSCmdlet.ShouldProcess($extra.File.FullName, "Delete (keeping $($keep.File.Name))")) { Remove-Item -LiteralPath $extra.File.FullName -Force -ErrorAction Stop $result.Status = 'Deleted' } } else { $destDir = Join-Path $folder.FullName $DuplicateFolder $dest = Join-Path $destDir $extra.File.Name $n = 2 while (Test-Path -LiteralPath $dest) { $dest = Join-Path $destDir ('{0} ({1}){2}' -f $extra.File.BaseName, $n++, $extra.File.Extension) } $result.Destination = $dest if ($PSCmdlet.ShouldProcess($extra.File.FullName, "Move to $destDir (keeping $($keep.File.Name))")) { if (-not (Test-Path -LiteralPath $destDir)) { $null = New-Item -ItemType Directory -Path $destDir -ErrorAction Stop } Move-Item -LiteralPath $extra.File.FullName -Destination $dest -ErrorAction Stop $result.Status = 'Moved' } } } catch { $result.Status = "Failed: $($_.Exception.Message)" Write-Warning "$($extra.File.Name): $($_.Exception.Message)" } [pscustomobject]$result } } } } }