Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

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.

AT A GLANCEMove-RomByKeyword.ps1
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.

Move-RomByKeyword.ps1Download
<#
.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

ParameterTypeDefaultWhat it's for
-Pathstring—Required. The folder to check.
-Keywordstring[]—Keywords to look for. Use this, -KeywordFile, or both.
-KeywordFilestring—A text file with one keyword per line. Lines starting with
-ReviewFolderstring_review inside PathWhere the matches go, with their subfolders kept.
-Recurseswitch—Check subfolders too. The review folder is always skipped.
-Extensionstring[]every fileOnly check these extensions.
-WholeWordswitch—Only match whole words, so "Pool" doesn't catch "Liverpool".
-Regexswitch—Treat the keywords as regular expressions.
-Exceptstring[]—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)' -WhatIf

Run 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 '\[!\]' -WhatIf

Keep only English-language regions, using a pattern.

.\Move-RomByKeyword.ps1 -Path D:\Games\genesis -Keyword '\((France|Germany|Spain|Italy|Brazil|Korea)\)' -Regex

What you'll see

Example outputvalues are illustrative
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

  1. Gather the keywords from -Keyword and -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.
  2. List the files, optionally through subfolders and only for the extensions you gave, always skipping the review folder.
  3. Check the exceptions first. A name that matches an -Except pattern is never moved.
  4. Find the first keyword that matches the file name.
  5. Move it, keeping its path relative to the starting folder, so snes\Mahjong Master (Japan).sfc lands 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.