SCRIPT LIBRARY · POWERSHELL
Tag Game Backups With a Genre Code From a CSV
Renames game files to "(GENRE) Title (REGION) (NOTES)" using a CSV of titles and genre codes you keep, with short region codes, note codes for betas and hacks, and a list of everything it couldn't place.
- What it does
- Renames game backups to "(GENRE) Title (REGION) (NOTES).ext", looking each title up in a CSV of title patterns and genre codes, converting region tags to short codes and dump tags like (Beta) or [T+Eng] to note codes. Unmatched titles are reported, prompted for, or given a default code.
- Requires
- Windows PowerShell 5.1 or PowerShell 7+
- No modules
- A CSV with Title and Genre columns that you put together
- 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 folders of empty dummy files, with -WhatIf, for real, with -DefaultGenre, already-tagged files, series fallbacks, multi-region and hack tags
Part 5 of the thread Keeping a media library tidy
I used to keep a separate rename script for each system, NES in one, Dreamcast in another, each with a long list of titles and genres pasted into the top of the script itself. Every new game meant editing code.
This is the one general version. The titles and genre codes live in a CSV, so one script covers every system, and the naming follows the convention in my genre-code naming post: a genre code first so the folder sorts itself by type, then the title, then a short region code and any notes.
Anything it can't place is left alone and listed, so the first run doubles as a to-do list for the CSV.
<#
.SYNOPSIS
Renames game backups to "(GENRE) Title (REGION) (NOTES).ext", looking the genre up in a CSV you keep.
.DESCRIPTION
For every file it:
- takes the title from the part of the name before the first bracket
- looks the title up in a CSV of title patterns and genre codes (columns: Title, Genre). A row matches when its
Title appears anywhere in the game's title, ignoring case and punctuation, and the longest match wins,
so a "Mega Man X" row beats a "Mega Man" row
- turns the region tag into a short code: (USA) or (U) becomes USA, (Japan) becomes JPN, (Europe) becomes EUR,
and (World) becomes "USA EUR JPN"
- turns tags like (Beta), (Proto), (Demo), (Unl), (Hack) and [T+Eng] into note codes
- skips files that already start with a genre code, unless you pass -Retag
Titles with no match are reported with Status NoGenre and left alone. Pass -DefaultGenre to use a catch-all code,
or -Prompt to be asked for each one (answers can be saved back to the CSV with -SaveMap).
Every file gets an object back with the old and new names. Supports -WhatIf.
.PARAMETER Path
One or more folders (or single files). Defaults to the current folder.
.PARAMETER MapCsv
CSV with Title and Genre columns. Genre codes are 3 or 4 capital letters.
.PARAMETER Extension
File extensions to consider. Default: every file.
.PARAMETER Recurse
Include subfolders.
.PARAMETER DefaultGenre
Code to use when nothing in the CSV matches, for example MISC.
.PARAMETER DefaultRegion
Region code for files with no recognizable region tag. Default: USA.
.PARAMETER Retag
Also process files that already start with a genre code, replacing the old code.
.PARAMETER Prompt
Ask for a genre code for each unmatched title.
.PARAMETER SaveMap
With -Prompt, append your answers to the CSV so the next run knows them.
.EXAMPLE
.\Add-RomGenreTag.ps1 -Path D:\Games\nes -MapCsv .\nes-genres.csv -WhatIf
.EXAMPLE
.\Add-RomGenreTag.ps1 -Path D:\Games\dreamcast -MapCsv .\dc-genres.csv -Extension .chd -Prompt -SaveMap
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
[Alias('FullName')]
[string[]]$Path = '.',
[Parameter(Mandatory)]
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
[string]$MapCsv,
[string[]]$Extension,
[switch]$Recurse,
[ValidatePattern('^[A-Z]{3,4}$')]
[string]$DefaultGenre,
[ValidatePattern('^[A-Z]{3}( [A-Z]{3})*$')]
[string]$DefaultRegion = 'USA',
[switch]$Retag,
[switch]$Prompt,
[switch]$SaveMap
)
begin {
function Get-MatchKey([string]$Text) { ' ' + ($Text.ToLowerInvariant() -replace '[\u0027\u2019]', '' -replace '&', 'and' -replace '[^a-z0-9]+', ' ').Trim() + ' ' }
$map = [System.Collections.Generic.List[object]]::new()
foreach ($row in Import-Csv -LiteralPath $MapCsv) {
if (-not $row.Title -or -not $row.Genre) { continue }
$code = $row.Genre.Trim().ToUpperInvariant()
if ($code -notmatch '^[A-Z]{3,4}$') { Write-Warning "Skipping CSV row '$($row.Title)': '$($row.Genre)' isn't a 3 or 4 letter code."; continue }
$map.Add([pscustomobject]@{ Key = (Get-MatchKey $row.Title); Genre = $code })
}
Write-Verbose "Loaded $($map.Count) title patterns from $MapCsv"
$regionCodes = @{
'usa' = 'USA'; 'us' = 'USA'; 'u' = 'USA'; 'japan' = 'JPN'; 'jp' = 'JPN'; 'j' = 'JPN'; 'europe' = 'EUR'; 'eu' = 'EUR'; 'e' = 'EUR'
'france' = 'FRA'; 'f' = 'FRA'; 'germany' = 'GER'; 'g' = 'GER'; 'spain' = 'ESP'; 's' = 'ESP'; 'italy' = 'ITA'; 'i' = 'ITA'
'world' = 'USA EUR JPN'; 'w' = 'USA EUR JPN'; 'ue' = 'USA EUR'; 'ju' = 'JPN USA'; 'jue' = 'USA EUR JPN'
}
$noteCodes = [ordered]@{
'^beta( \d+)?$' = 'BETA'; '^(proto|prototype)$' = 'PROT'; '^demo$' = 'DEMO'; '^(hack|h\d*\w*)$' = 'HACK'
'^(unl|unlicensed)$' = 'UNLS'; '^t[+-]\w+' = 'TRAD'; '^(translated|fan translation)$' = 'TRAD'; '^re-?release$' = 'RELN'
}
$extFilter = @($Extension | Where-Object { $_ } | ForEach-Object { if ($_ -like '.*') { $_.ToLower() } else { ".$_".ToLower() } })
$newRows = [System.Collections.Generic.List[object]]::new()
function Get-Genre([string]$Title) {
$key = Get-MatchKey $Title
$best = $map | Where-Object { $key.Contains($_.Key) } | Sort-Object { $_.Key.Length } -Descending | Select-Object -First 1
if ($best) { return $best.Genre }
if ($Prompt) {
do { $answer = (Read-Host "Genre code for '$Title' (3-4 letters, Enter to skip)").Trim().ToUpperInvariant() }
until (-not $answer -or $answer -match '^[A-Z]{3,4}$')
if ($answer) {
$map.Add([pscustomobject]@{ Key = $key; Genre = $answer })
$newRows.Add([pscustomobject]@{ Title = $Title; Genre = $answer })
return $answer
}
}
$DefaultGenre
}
}
process {
foreach ($item in $Path) {
$files = if (Test-Path -LiteralPath $item -PathType Container) {
Get-ChildItem -LiteralPath $item -File -Recurse:$Recurse | Where-Object { -not $extFilter -or $extFilter -contains $_.Extension.ToLower() }
}
elseif (Test-Path -LiteralPath $item -PathType Leaf) { Get-Item -LiteralPath $item }
else { Write-Warning "Not found: $item"; continue }
$planned = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($file in $files) {
$result = [ordered]@{ Folder = $file.DirectoryName; OldName = $file.Name; NewName = $file.Name; Genre = $null; Status = 'Unchanged' }
$base = $file.BaseName
if ($base -match '^\(([A-Z]{3,4})\)\s*(.*)$') {
if (-not $Retag) { $result.Status = 'AlreadyTagged'; [pscustomobject]$result; continue }
$base = $Matches[2]
}
$title = ($base -replace '\s*[\(\[].*$', '').Trim()
if (-not $title) { $result.Status = 'NoTitle'; [pscustomobject]$result; continue }
$regions = [System.Collections.Generic.List[string]]::new()
$notes = [System.Collections.Generic.List[string]]::new()
foreach ($m in [regex]::Matches($base.Substring($base.IndexOf($title) + $title.Length), '[\(\[]([^\)\]]+)[\)\]]')) {
$tag = $m.Groups[1].Value.Trim()
$parts = @($tag -split '\s*,\s*' | ForEach-Object { $_.ToLowerInvariant() })
if (@($parts | Where-Object { $regionCodes.ContainsKey($_) }).Count -eq $parts.Count) {
foreach ($p in $parts) { foreach ($c in ($regionCodes[$p] -split ' ')) { if (-not $regions.Contains($c)) { $regions.Add($c) } } }
continue
}
foreach ($pattern in $noteCodes.Keys) {
if ($tag -match $pattern -and -not $notes.Contains($noteCodes[$pattern])) { $notes.Add($noteCodes[$pattern]); break }
}
}
$region = if ($regions.Count) { $regions -join ' ' } else { $DefaultRegion }
$genre = Get-Genre $title
$result.Genre = $genre
if (-not $genre) {
$result.Status = 'NoGenre'
Write-Verbose "No genre for '$title'"
[pscustomobject]$result; continue
}
$newName = "($genre) $title ($region)"
if ($notes.Count) { $newName += " ($($notes -join ' '))" }
$newName = ($newName -replace '[\\/:*?"<>|]', '-') + $file.Extension
$result.NewName = $newName
if ($newName -ceq $file.Name) { [pscustomobject]$result; continue }
$target = Join-Path $file.DirectoryName $newName
$caseOnly = $newName -eq $file.Name
if (-not $planned.Add($target) -or (-not $caseOnly -and (Test-Path -LiteralPath $target))) {
$result.Status = 'SkippedExists'
Write-Warning "$($file.Name): '$newName' already exists (or another file would get that name). Left alone."
[pscustomobject]$result; continue
}
$result.Status = 'WhatIf'
if ($PSCmdlet.ShouldProcess($file.FullName, "Rename to $newName")) {
try {
if ($caseOnly) {
$temp = $file.Name + '.' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.tmp'
Rename-Item -LiteralPath $file.FullName -NewName $temp -ErrorAction Stop
Rename-Item -LiteralPath (Join-Path $file.DirectoryName $temp) -NewName $newName -ErrorAction Stop
}
else { Rename-Item -LiteralPath $file.FullName -NewName $newName -ErrorAction Stop }
$result.Status = 'Renamed'
}
catch {
$result.Status = "Failed: $($_.Exception.Message)"
Write-Warning "$($file.Name): $($_.Exception.Message)"
}
}
[pscustomobject]$result
}
}
}
end {
if ($SaveMap -and $newRows.Count -and $PSCmdlet.ShouldProcess($MapCsv, "Add $($newRows.Count) new title(s)")) {
$newRows | Export-Csv -LiteralPath $MapCsv -Append -NoTypeInformation
}
}
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-Path | string[] | . | Folders or single files. Takes pipeline input. |
-MapCsv | string | — | Required. CSV with Title and Genre columns. A row matches when its title appears anywhere in the game's title, and the longest match wins. |
-Extension | string[] | every file | Only rename these extensions, so saves and artwork are left alone. |
-Recurse | switch | — | Include subfolders. |
-DefaultGenre | string | — | A catch-all code, like MISC, for titles the CSV doesn't cover. |
-DefaultRegion | string | USA | Region code for files with no region tag. |
-Retag | switch | — | Also redo files that already start with a genre code. |
-Prompt | switch | — | Ask for a code for each unmatched title. |
-SaveMap | switch | — | With -Prompt, add your answers to the CSV for next time. |
Run it
Preview the renames for one system.
.\Add-RomGenreTag.ps1 -Path D:\Games\nes -MapCsv .\nes-genres.csv -WhatIfOnly list what the CSV doesn't cover yet.
.\Add-RomGenreTag.ps1 -Path D:\Games\nes -MapCsv .\nes-genres.csv -WhatIf | Where-Object Status -eq NoGenreTag a Dreamcast folder, asking about unknown titles and saving the answers.
.\Add-RomGenreTag.ps1 -Path D:\Games\dreamcast -MapCsv .\dc-genres.csv -Extension .chd -Prompt -SaveMapTag everything, putting anything unknown under MISC, and keep a record.
.\Add-RomGenreTag.ps1 -Path D:\Games\snes -MapCsv .\snes-genres.csv -DefaultGenre MISC | Export-Csv tagged.csv -NoTypeInformationWhat you'll see
OldName NewName Genre Status
------- ------- ----- ------
(PLAT) Already Done (USA).sfc (PLAT) Already Done (USA).sfc PLAT AlreadyTagged
Mega Man 2 (U) [!].nes (ACTN) Mega Man 2 (USA).nes ACTN Renamed
Mega Man X (USA).sfc (PLAT) Mega Man X (USA).sfc PLAT Renamed
Space Blaster (Europe) (Beta).sfc (SHOT) Space Blaster (EUR) (BETA).sfc SHOT Renamed
Super Metroid (Japan, USA) (En,Ja).sfc (METV) Super Metroid (JPN USA).sfc METV Renamed
Tetris (World).gb (PUZZ) Tetris (USA EUR JPN).gb PUZZ Renamed
Unknown Game (USA).sfc Unknown Game (USA).sfc NoGenre
How it works
- Load the CSV. Each row's title is squashed to lowercase letters and numbers (apostrophes dropped, & read as "and"), and each genre code is checked for 3 or 4 capital letters. Bad rows get a warning.
- Skip what's done. Files that already start with a code like
(PLAT)are reported as AlreadyTagged, unless you pass-Retag. - Read the name. The title is everything before the first bracket. The brackets after it are sorted into regions, like (USA), (U), (Japan, USA) and (World), and notes, like (Beta), (Proto), (Unl), (Hack) and [T+Eng].
- Find the genre. Every CSV row whose title appears inside the game's title is a candidate, and the longest wins. No match means the
-Promptquestion, the-DefaultGenre, or a NoGenre result. - Build the name as
(GENRE) Title (REGION) (NOTES).ext, replacing any characters Windows won't allow. - Rename safely. Names that already exist, or that another file in the same run is about to take, are skipped with a warning. Case-only renames go through a temporary name.
- Save new answers. With
-SaveMap, anything you typed at a prompt is appended to the CSV at the end.
Take it further
- Build the CSV from the NoGenre list. Run with
-WhatIf, export the NoGenre rows, fill in the codes in a spreadsheet, and append them to the CSV. - Sort by quality next. Move-RomToQualityTier reads the genre-tagged names and files them into tiers by review score.
- Clean before you tag. The order that works is Rename-RomFile, then Remove-DuplicateRom, then this.
Things that'll trip you up
- Short patterns catch a lot. A row like "Mario" matches every game with Mario in the title. That's handy as a series fallback, and the longest match always wins, so "Mario Kart" can sit alongside it. Just don't add a row that's a common word.
- Frontends may lose their artwork. Scrapers that match games by file name won't recognize the new names. Scrapers that hash the file don't care. Rename before you scrape, or keep a separately named copy for the frontend.
- Clean up first. The title is everything before the first bracket. Old GoodTools names with odd tags work, but they come out better after a pass with Rename-RomFile, and deduplicating first means fewer files to tag.
- The default region is a guess. Files with no region tag get USA unless you say otherwise with -DefaultRegion. Check the NoGenre and region results on the first run before trusting it with a whole library.