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

SCRIPT LIBRARY · POWERSHELL

Sort a Comic or Manga Library into Genre Folders from a CSV

Move CBZ and CBR files, or whole series folders, into genre or decade folders using a series list you keep in a spreadsheet, then list what didn't match and check nothing went missing.

AT A GLANCEMove-ComicByGenre.ps1
What it does
Works out the series name for every comic file and series folder in a folder, looks it up in a CSV of series, genre and year, and moves it into a genre or decade folder. Unmatched items are reported (and optionally moved to their own folder), and the comic file count is checked before and after.
Requires
  • Windows PowerShell 5.1 or PowerShell 7+
  • No modules
  • A CSV with a Series column, plus Genre and/or Year
Permissions
Read and write access to the library folder. No admin rights.
Runs on
Windows 10/11, macOS or Linux with PowerShell 7
Tested
Parse-checked and run in PowerShell 7.4 against a folder of dummy .cbz and .cbr files and series folders, with -WhatIf, for real, -GroupBy Decade to a separate destination, an -UncategorizedFolder, a re-run, and merging into a series folder that already existed

Part 2 of the thread Keeping a media library tidy

A comic library usually starts as one big folder, sorted A to Z, which is fine until it isn't. Past a few hundred series you stop browsing and start searching, and "what was that horror one?" isn't a search. Genre folders fix that, and so do decade folders if you read that way.

I did this to my own manga folder with a pile of little scripts: one to move things, one to find what didn't get moved, one to count everything afterwards to make sure nothing fell through the cracks. The genre list was pasted straight into the script. This version takes the list from a CSV instead, so the mapping lives in a spreadsheet where it belongs, and it does the moving, the "what's left" report and the count check in one run.

It works on loose CBZ and CBR files, series folders, or a mix of both.

Move-ComicByGenre.ps1Download
<#
.SYNOPSIS
    Sorts comic and manga files (or whole series folders) into genre or decade folders from a CSV you provide.
.DESCRIPTION
    Looks at every comic file (.cbz, .cbr, .cb7 by default) and every series folder directly inside -Path,
    works out the series name, and looks it up in a CSV with Series, Genre and Year columns. Matching items
    are moved into <Destination>\<Genre> (or <Destination>\<Decade>, like 1990s, with -GroupBy Decade).

    Series names come from the folder or file name, with the usual clutter removed: a trailing (2019),
    [scanlator] tags, and volume or issue numbers like v01, Vol. 3, #012, c045 or a bare 012. If there's no
    exact match, the longest series in the CSV that the name starts with wins, so "Series - Side Story"
    lands with "Series". For -GroupBy Decade, the year comes from the CSV, or from a (1998) in the name.

    Anything it can't place is reported with Status Uncategorized and left alone, unless you name an
    -UncategorizedFolder. When it's done, it counts comic files again and warns if the totals don't match.
    Supports -WhatIf.
.PARAMETER Path
    The folder to sort. Only items directly inside it are moved.
.PARAMETER MapCsv
    CSV with a Series column plus Genre and/or Year. Extra columns are ignored.
.PARAMETER Destination
    Where the genre or decade folders go. Defaults to -Path.
.PARAMETER GroupBy
    Genre (default) or Decade.
.PARAMETER Extension
    Comic file extensions. Default: .cbz, .cbr, .cb7.
.PARAMETER UncategorizedFolder
    Folder name to move unmatched items into. By default they stay where they are.
.EXAMPLE
    .\Move-ComicByGenre.ps1 -Path D:\Manga -MapCsv .\series.csv -WhatIf
.EXAMPLE
    .\Move-ComicByGenre.ps1 -Path D:\Manga -MapCsv .\series.csv | Where-Object Status -eq Uncategorized | Select-Object Name, Series
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)][ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })][string]$Path,
    [Parameter(Mandatory)][ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })][string]$MapCsv,
    [string]$Destination,
    [ValidateSet('Genre', 'Decade')][string]$GroupBy = 'Genre',
    [string[]]$Extension = @('.cbz', '.cbr', '.cb7'),
    [ValidatePattern('^[^\\/:*?"<>|]+$')][string]$UncategorizedFolder
)

$Path = (Resolve-Path -LiteralPath $Path).ProviderPath
$Destination = if ($Destination) { $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Destination) } else { $Path }
$Extension = @($Extension | ForEach-Object { if ($_ -like '.*') { $_.ToLower() } else { ".$_".ToLower() } })

function Get-MatchKey([string]$Text) { ($Text.ToLowerInvariant() -replace '&', ' and ' -replace '[^\p{L}\p{N}]+', ' ').Trim() }

function Get-SeriesName([string]$Name) {
    $n = $Name -replace '\[[^\]]*\]', '' -replace '\((19|20)\d{2}\)', '' -replace '\((Digital|Complete|Ongoing|[^)]*Scan[^)]*)\)', '' -replace '_', ' '
    # Volume, issue and chapter numbers at the end: v01, Vol. 3, Volume 03, #012, c045, Chapter 12, or a bare 012.
    $n = $n -replace '(?i)[\s\-]+(v|vol\.?|volume|c|ch\.?|chapter|#|issue)\s*\d+(\.\d+)?(\s*-\s*\d+)?\b.*$', ''
    $n = $n -replace '\s+\d{1,4}(\.\d+)?\s*$', ''
    ($n -replace '\s{2,}', ' ').Trim(' ', '-', '.')
}

function Get-ComicCount([string[]]$Roots) {
    $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    foreach ($r in $Roots | Select-Object -Unique) {
        if (Test-Path -LiteralPath $r) {
            Get-ChildItem -LiteralPath $r -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $Extension -contains $_.Extension.ToLower() } | ForEach-Object { $null = $seen.Add($_.FullName) }
        }
    }
    $seen.Count
}

# Load the map. Keys are normalized so punctuation and case don't matter.
$map = @{}
foreach ($row in Import-Csv -LiteralPath $MapCsv) {
    if (-not $row.Series) { continue }
    $map[(Get-MatchKey $row.Series)] = [pscustomobject]@{ Series = $row.Series.Trim(); Genre = "$($row.Genre)".Trim(); Year = "$($row.Year)".Trim() }
}
if (-not $map.Count) { throw "No rows with a Series column found in $MapCsv." }
$keysByLength = $map.Keys | Sort-Object Length -Descending
Write-Verbose "Loaded $($map.Count) series from $MapCsv"

# Category folders this run could create, so a re-run doesn't try to sort them.
$categoryNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($entry in $map.Values) { if ($entry.Genre) { $null = $categoryNames.Add(($entry.Genre -replace '[\\/:*?"<>|]', '-')) } }
if ($UncategorizedFolder) { $null = $categoryNames.Add($UncategorizedFolder) }

$before = Get-ComicCount @($Path, $Destination)
$items = Get-ChildItem -LiteralPath $Path | Where-Object {
    ($_.PSIsContainer -and -not $categoryNames.Contains($_.Name) -and $_.Name -notmatch '^\d{4}s$' -and $_.FullName -ne $Destination) -or
    (-not $_.PSIsContainer -and $Extension -contains $_.Extension.ToLower())
}

foreach ($item in $items) {
    $baseName = if ($item.PSIsContainer) { $item.Name } else { $item.BaseName }
    $series = Get-SeriesName $baseName
    $key = Get-MatchKey $series
    $entry = $map[$key]
    if (-not $entry) {
        $prefix = $keysByLength | Where-Object { $key.StartsWith("$_ ") } | Select-Object -First 1
        if ($prefix) { $entry = $map[$prefix] }
    }

    $category = $null
    if ($entry -and $GroupBy -eq 'Genre' -and $entry.Genre) { $category = $entry.Genre -replace '[\\/:*?"<>|]', '-' }
    elseif ($GroupBy -eq 'Decade') {
        $year = if ($entry -and $entry.Year -match '^\d{4}$') { [int]$entry.Year } elseif ($baseName -match '\(((19|20)\d{2})\)') { [int]$Matches[1] }
        if ($year) { $category = '{0}s' -f ([math]::Floor($year / 10) * 10) }
    }

    $result = [ordered]@{ Name = $item.Name; Type = $(if ($item.PSIsContainer) { 'Folder' } else { 'File' }); Series = $series; MatchedAs = $(if ($entry) { $entry.Series }); Category = $category; Destination = $null; Status = $null }
    if (-not $category) {
        $result.Status = 'Uncategorized'
        if (-not $UncategorizedFolder) { [pscustomobject]$result; continue }
        $category = $UncategorizedFolder
        $result.Category = $category
    }

    $catDir = Join-Path $Destination $category
    $target = Join-Path $catDir $item.Name
    $result.Destination = $target
    $leftBehind = 0
    $fileCount = if ($item.PSIsContainer) { Get-ComicCount $item.FullName } else { 1 }

    try {
        if (-not $PSCmdlet.ShouldProcess($item.FullName, "Move to $catDir")) { if (-not $result.Status) { $result.Status = 'WhatIf' }; [pscustomobject]$result; continue }
        if (-not (Test-Path -LiteralPath $catDir)) { $null = New-Item -ItemType Directory -Path $catDir -ErrorAction Stop }

        if (-not (Test-Path -LiteralPath $target)) {
            Move-Item -LiteralPath $item.FullName -Destination $target -ErrorAction Stop
        }
        elseif ($item.PSIsContainer) {
            # The series folder already exists there (an earlier run, or a split library): merge into it.
            foreach ($child in Get-ChildItem -LiteralPath $item.FullName) {
                $childTarget = Join-Path $target $child.Name
                if (Test-Path -LiteralPath $childTarget) { Write-Warning "$($item.Name): '$($child.Name)' already exists in $target. Left in place."; $leftBehind++; continue }
                Move-Item -LiteralPath $child.FullName -Destination $childTarget -ErrorAction Stop
            }
            if (-not (Get-ChildItem -LiteralPath $item.FullName -Force)) { Remove-Item -LiteralPath $item.FullName -ErrorAction Stop }
        }
        else { throw "A file named '$($item.Name)' already exists in $catDir." }

        if ($leftBehind) { $result.Status = "PartlyMoved: $leftBehind item(s) already existed"; [pscustomobject]$result; continue }
        $moved = if ($item.PSIsContainer) { Get-ComicCount $target } else { [int](Test-Path -LiteralPath $target) }
        $result.Status = if ($moved -ge $fileCount) { if ($result.Status -eq 'Uncategorized') { 'MovedUncategorized' } else { 'Moved' } } else { "Check: $fileCount comic file(s) before, $moved after" }
    }
    catch {
        $result.Status = "Failed: $($_.Exception.Message)"
        Write-Warning "$($item.Name): $($_.Exception.Message)"
    }
    [pscustomobject]$result
}

if (-not $WhatIfPreference) {
    $after = Get-ComicCount @($Path, $Destination)
    if ($after -ne $before) { Write-Warning "Comic file count changed: $before before, $after after. Something went missing or got duplicated; check the Failed rows." }
    else { Write-Verbose "Verified: $after comic file(s) before and after." }
}

Parameters

ParameterTypeDefaultWhat it's for
-Pathstring—The folder to sort. Only items directly inside it are moved; a series folder moves as a whole.
-MapCsvstring—CSV with a Series column and a Genre and/or Year column. Any other columns are ignored.
-Destinationstringsame as PathWhere the genre or decade folders go. Point it somewhere else to build a sorted copy of the layout.
-GroupBystringGenreGenre, or Decade (1980s, 1990s...). Decade uses the CSV year, or a (1998) in the name.
-Extensionstring[].cbz, .cbr, .cb7Which loose files count as comics.
-UncategorizedFolderstring—Move anything it can't place into this folder. Leave it off and those items stay where they are.

Run it

See where everything would go.

.\Move-ComicByGenre.ps1 -Path D:\Manga -MapCsv .\series.csv -WhatIf

Just the list of series the CSV doesn't cover yet, ready to paste into the spreadsheet.

.\Move-ComicByGenre.ps1 -Path D:\Manga -MapCsv .\series.csv -WhatIf | Where-Object Status -eq Uncategorized | Select-Object Series -Unique | Export-Csv missing.csv -NoTypeInformation

Sort by decade into a new top-level folder, and park the leftovers.

.\Move-ComicByGenre.ps1 -Path D:\Comics -MapCsv .\series.csv -GroupBy Decade -Destination D:\ComicsByDecade -UncategorizedFolder Unsorted

Do it for real, with the count check shown at the end.

.\Move-ComicByGenre.ps1 -Path D:\Manga -MapCsv .\series.csv -Verbose

What you'll see

Example outputvalues are illustrative
Name                              Type   Series                     MatchedAs Category      Status
----                              ----   ------                     --------- --------      ------
Akira (1982)                      Folder Akira                      Akira     Sci-Fi        Moved
Berserk v01 (Digital) [Group].cbz File   Berserk                    Berserk   Dark Fantasy  Moved
Berserk Vol. 2.cbr                File   Berserk                    Berserk   Dark Fantasy  Moved
Harbor Lights                     Folder Harbor Lights                                      Uncategorized
Yotsuba&! #003.cbz                File   Yotsuba&!                  Yotsuba&! Slice of Life Moved
Yotsuba&! - Extra Story 01.cbz    File   Yotsuba&! - Extra Story    Yotsuba&! Slice of Life Moved
VERBOSE: Verified: 412 comic file(s) before and after.

How it works

  1. Load the map. Every CSV row becomes a lookup entry keyed on the series name, lowercased, with punctuation and & normalized, so "Yotsuba&!" and "Yotsuba & !" are the same key.
  2. Work out each item's series. For a folder it's the folder name; for a file it's the file name. Either way it strips a trailing (1998), [group] tags, (Digital) and similar notes, then volume, issue and chapter numbers: v01, Vol. 3, #012, c045, or a bare number at the end.
  3. Find a category. An exact match wins; otherwise the longest series in the CSV that the name starts with. For -GroupBy Decade, the year comes from the CSV or from the name.
  4. Move it. Items go into <Destination>\<Genre> through ShouldProcess. If the series folder already exists there, from an earlier run or a split library, the contents are merged in, and any file that already exists is left in place and reported as PartlyMoved.
  5. Check the count. It counts every comic file under the source and destination before it starts, and again at the end. If the numbers differ you get a warning; with -Verbose you also see the match. Each moved series folder is counted too.

Take it further

  • Convert while you're sorting. If half the library is CBR, Convert-ComicArchive turns it into CBZ first, so everything matches the default extensions and your reader.
  • Shrink it afterwards. Comic Cruncher is what I use to squeeze a sorted library down and bind loose issues into trades.
  • Remove the empty folders. After a big reshuffle, Get-ChildItem -Directory -Recurse | Where-Object { -not (Get-ChildItem $_.FullName -Force) } | Remove-Item -WhatIf finds the husks left behind.

Things that'll trip you up

  • The CSV is the whole trick. Three columns is all it needs: Series,Genre,Year, one row per series, like "Akira,Sci-Fi,1982". Start by running with -WhatIf, export the Uncategorized list, fill in genres in a spreadsheet, and run again. It gets quick after the first pass.
  • One genre per series. A folder can only live in one place. If a series is horror and comedy, pick the one you'd look under first. If you really want both, a library server with tags (Komga, Kavita) is a better fit than folders.
  • Prefix matching can over-reach. When there's no exact match, the longest series name the item starts with wins, so side stories follow the main series. The catch is that a short name like "Blue" would also claim "Blue Lock". Keep short, generic names out of the CSV, or rename the item.
  • Library servers will rescan. Moving files changes their paths, so Komga, Kavita or Calibre will see them as new. Reading progress usually survives if the server matches by file hash, but check one series before moving thousands.