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

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.

AT A GLANCEAdd-RomGenreTag.ps1
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.

Add-RomGenreTag.ps1Download
<#
.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

ParameterTypeDefaultWhat it's for
-Pathstring[].Folders or single files. Takes pipeline input.
-MapCsvstring—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.
-Extensionstring[]every fileOnly rename these extensions, so saves and artwork are left alone.
-Recurseswitch—Include subfolders.
-DefaultGenrestring—A catch-all code, like MISC, for titles the CSV doesn't cover.
-DefaultRegionstringUSARegion code for files with no region tag.
-Retagswitch—Also redo files that already start with a genre code.
-Promptswitch—Ask for a code for each unmatched title.
-SaveMapswitch—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 -WhatIf

Only list what the CSV doesn't cover yet.

.\Add-RomGenreTag.ps1 -Path D:\Games\nes -MapCsv .\nes-genres.csv -WhatIf | Where-Object Status -eq NoGenre

Tag 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 -SaveMap

Tag 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 -NoTypeInformation

What you'll see

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

  1. 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.
  2. Skip what's done. Files that already start with a code like (PLAT) are reported as AlreadyTagged, unless you pass -Retag.
  3. 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].
  4. 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 -Prompt question, the -DefaultGenre, or a NoGenre result.
  5. Build the name as (GENRE) Title (REGION) (NOTES).ext, replacing any characters Windows won't allow.
  6. 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.
  7. 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.