SCRIPT LIBRARY · POWERSHELL
Set Aside Unwanted ROMs by Keyword, Without Deleting Anything
Moves game files with keywords like (Beta), (Proto), Mahjong or (Germany) in their names into a review folder, keeping their subfolders, so you can trim a huge set down to what you'd actually play and change your mind later.
- What it does
- Checks every file name in a folder against a list of keywords and moves the matches into a review folder, keeping their subfolder paths. Supports whole-word matching, regular expressions and exceptions like the [!] verified flag. Nothing is deleted.
- Requires
- Windows PowerShell 5.1 or PowerShell 7+
- No modules
- Permissions
- Read and write access to the game folders. No admin rights.
- Runs on
- Windows 10/11, macOS or Linux with PowerShell 7
- Tested
- Parse-checked and run in PowerShell 7.4 against nested folders of empty dummy files, with -WhatIf, for real, -WholeWord, an exception pattern and a keyword file
Part 8 of the thread Keeping a media library tidy
A full set for a console is full of things you'll never play: betas, demos, diagnostic carts, mahjong and pachinko games, quiz games in a language you don't read, and six European versions of the same football game. Trimming them makes a frontend far nicer to scroll.
My old version of this deleted the matches outright. That's fine until "Pool" takes out a game you wanted because it happens to be in the title. So this one moves the matches into a review folder instead, keeping each file's subfolder, and tells you which keyword caught it.
Look through the review folder, move back anything that shouldn't be there, and delete the rest when you're sure.
<#
.SYNOPSIS
Moves game files whose names contain unwanted keywords into a review folder, instead of deleting them.
.DESCRIPTION
Checks every file name against a list of keywords, like "(Beta)", "(Proto)", "Mahjong" or "(Germany)", and moves
each match into a review folder, keeping its subfolder path so you can put anything back where it came from.
Keywords are matched as plain text, ignoring case. Use -WholeWord so "Pool" doesn't catch "Liverpool", or
-Regex to write your own patterns. Files matching an -Except pattern are never moved (for example "[!]", the
verified-dump flag).
Nothing is deleted. Once you've looked through the review folder, deleting it is up to you.
Every match gets an object back with the keyword that caught it. Supports -WhatIf.
.PARAMETER Path
The folder to check.
.PARAMETER Keyword
Keywords to look for. Combine with -KeywordFile or use either one.
.PARAMETER KeywordFile
A text file with one keyword per line. Blank lines and lines starting with # are ignored.
.PARAMETER ReviewFolder
Where matches go. Default: a "_review" folder inside -Path.
.PARAMETER Recurse
Check subfolders too (the review folder itself is always skipped).
.PARAMETER Extension
Only check these extensions. Default: every file.
.PARAMETER WholeWord
Only match keywords as whole words.
.PARAMETER Regex
Treat keywords as regular expressions.
.PARAMETER Except
Regular expressions for names that must never be moved.
.EXAMPLE
.\Move-RomByKeyword.ps1 -Path D:\Games\snes -Keyword '(Beta)','(Proto)','(Demo)' -WhatIf
.EXAMPLE
.\Move-RomByKeyword.ps1 -Path D:\Games -Recurse -KeywordFile .\skip-list.txt -WholeWord -Except '\[!\]' | Export-Csv moved.csv -NoTypeInformation
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
[string]$Path,
[string[]]$Keyword,
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
[string]$KeywordFile,
[string]$ReviewFolder,
[switch]$Recurse,
[string[]]$Extension,
[switch]$WholeWord,
[switch]$Regex,
[string[]]$Except
)
$root = (Resolve-Path -LiteralPath $Path).ProviderPath.TrimEnd('\', '/')
if (-not $ReviewFolder) { $ReviewFolder = Join-Path $root '_review' }
$reviewFull = [System.IO.Path]::GetFullPath($ReviewFolder).TrimEnd('\', '/')
$words = @($Keyword | Where-Object { $_ })
if ($KeywordFile) { $words += Get-Content -LiteralPath $KeywordFile | ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') } }
if (-not $words.Count) { throw 'Give at least one keyword with -Keyword or -KeywordFile.' }
$patterns = foreach ($w in $words) {
$p = if ($Regex) { $w } else { [regex]::Escape($w) }
if ($WholeWord) { $p = "(?<![A-Za-z0-9])$p(?![A-Za-z0-9])" }
try { [pscustomobject]@{ Keyword = $w; Re = [regex]::new($p, 'IgnoreCase') } }
catch { Write-Warning "Skipping keyword '$w': not a valid pattern." }
}
$extFilter = @($Extension | Where-Object { $_ } | ForEach-Object { if ($_ -like '.*') { $_.ToLower() } else { ".$_".ToLower() } })
$files = Get-ChildItem -LiteralPath $root -File -Recurse:$Recurse | Where-Object {
(-not $extFilter -or $extFilter -contains $_.Extension.ToLower()) -and
-not $_.FullName.StartsWith($reviewFull + [System.IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)
}
foreach ($file in $files) {
if (@($Except | Where-Object { $_ -and $file.Name -match $_ }).Count) { continue }
$hit = $patterns | Where-Object { $_.Re.IsMatch($file.Name) } | Select-Object -First 1
if (-not $hit) { continue }
$relative = $file.FullName.Substring($root.Length).TrimStart('\', '/')
$dest = Join-Path $reviewFull $relative
$result = [ordered]@{ Name = $file.Name; Keyword = $hit.Keyword; From = $file.DirectoryName; To = (Split-Path $dest -Parent); Status = 'WhatIf' }
if (Test-Path -LiteralPath $dest) {
$result.Status = 'SkippedExists'
Write-Warning "$relative is already in the review folder. Left alone."
[pscustomobject]$result; continue
}
if ($PSCmdlet.ShouldProcess($file.FullName, "Move to review folder (matched '$($hit.Keyword)')")) {
try {
$destDir = Split-Path $dest -Parent
if (-not (Test-Path -LiteralPath $destDir)) { New-Item -ItemType Directory -Path $destDir -ErrorAction Stop | Out-Null }
Move-Item -LiteralPath $file.FullName -Destination $dest -ErrorAction Stop
$result.Status = 'Moved'
}
catch {
$result.Status = "Failed: $($_.Exception.Message)"
Write-Warning "$($file.Name): $($_.Exception.Message)"
}
}
[pscustomobject]$result
}
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-Path | string | — | Required. The folder to check. |
-Keyword | string[] | — | Keywords to look for. Use this, -KeywordFile, or both. |
-KeywordFile | string | — | A text file with one keyword per line. Lines starting with |
-ReviewFolder | string | _review inside Path | Where the matches go, with their subfolders kept. |
-Recurse | switch | — | Check subfolders too. The review folder is always skipped. |
-Extension | string[] | every file | Only check these extensions. |
-WholeWord | switch | — | Only match whole words, so "Pool" doesn't catch "Liverpool". |
-Regex | switch | — | Treat the keywords as regular expressions. |
-Except | string[] | — | Regular expressions for names that must never be moved, like '\[!\]'. |
Run it
Preview setting aside betas, prototypes and demos from one system.
.\Move-RomByKeyword.ps1 -Path D:\Games\snes -Keyword '(Beta)','(Proto)','(Demo)' -WhatIfRun a saved keyword list across every system, whole words only, never touching verified dumps.
.\Move-RomByKeyword.ps1 -Path D:\Games -Recurse -KeywordFile .\skip-list.txt -WholeWord -Except '\[!\]' -WhatIfKeep only English-language regions, using a pattern.
.\Move-RomByKeyword.ps1 -Path D:\Games\genesis -Keyword '\((France|Germany|Spain|Italy|Brazil|Korea)\)' -RegexWhat you'll see
Name Keyword To Status
---- ------- -- ------
Pool Shark (USA).md Pool D:\Games\_review\genesis Moved
Mahjong Master (Japan).sfc Mahjong D:\Games\_review\snes Moved
Space Blaster (USA) (Beta).sfc (Beta) D:\Games\_review\snes Moved
How it works
- Gather the keywords from
-Keywordand-KeywordFile, skipping blank and comment lines. Each becomes a case-insensitive pattern: literal text by default, a whole-word match with-WholeWord, or your own regular expression with-Regex. - List the files, optionally through subfolders and only for the extensions you gave, always skipping the review folder.
- Check the exceptions first. A name that matches an
-Exceptpattern is never moved. - Find the first keyword that matches the file name.
- Move it, keeping its path relative to the starting folder, so
snes\Mahjong Master (Japan).sfclands in_review\snes\. If the review folder already has a file with that name, it's skipped with a warning.
Take it further
- Keep a skip list. A text file of the keywords you always use (sports, casino, quiz, (Beta), (Demo), the regions you don't want) makes this a one-line job for every new set.
- Deduplicate too. Regional copies of the same game are better handled by Remove-DuplicateRom, which keeps the best one.
- Sort what's left. After trimming, Move-RomToQualityTier puts the best games at the top.
Things that'll trip you up
- Keywords are blunt. "Baseball" catches a baseball game, but also "Super Baseball 2020", which is a fast arcade game some people love. Always run with -WhatIf and read the list.
- Some betas are worth keeping. Prototypes of cancelled or changed games can be more interesting than the release. Check the (Beta) and (Proto) matches before you delete them.
- Keep BIOS files out of the blast radius. A keyword like BIOS or System will happily catch the files your emulators need. Keep BIOS files in their own folder, outside the one you're cleaning.
- Plain text is the default. Brackets and dots in keywords are matched literally unless you pass -Regex. With -Regex, escape them yourself.
- Sort a Game Collection Into Tiers by Review ScoreScript Library
- Give Every Game Its Own Folder, With Updates and DLC AlongsideScript Library