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

SCRIPT LIBRARY · POWERSHELL

Fix Wrong Extensions and Find Duplicate Books in an Ebook Library

Check every book and comic file's real format from its first few bytes, rename the ones that lie, find identical copies by hash, and file whatever is loose in the library root.

AT A GLANCERepair-EbookLibrary.ps1
What it does
Runs three passes over an ebook and comic library. Renames files whose extension doesn't match their contents (PDF, EPUB, MOBI, DjVu, ZIP, RAR, 7z), reports or quarantines byte-identical duplicates found by SHA256, and files loose root files by your own CSV rules. Never deletes anything.
Requires
  • Windows PowerShell 5.1 or PowerShell 7+
  • No modules
Permissions
Read and write access to the library folder. No admin rights.
Runs on
Windows 10/11 (and PowerShell 7 on macOS or Linux)
Tested
Parse-checked and run in PowerShell 7.4 against a dummy library with -WhatIf and for real, covering PDF, ZIP, RAR, EPUB and MOBI signatures under the wrong extensions, an HTML error page saved as .epub, three identical copies of one book, a CSV rules file, and a second run to confirm nothing else changed

Part 1 of the thread Keeping a media library tidy

A book library that's been growing for years picks up odd habits. A comic that's really a PDF with .cbz stuck on the end. A .cbr that's actually a ZIP. The same book three times under three names, because it came from three places. And a pile of files dropped straight into the root, waiting to be dealt with "later".

Readers and library servers mostly cope, until they don't. A comic reader refuses to open a file, the server shows the same book twice, or a sync to an e-reader trips over something that isn't what it says it is.

This grew out of a one-off I wrote for my own library, which had my folder names and filename patterns baked right in. This version is general. It reads file signatures instead of trusting extensions, only hashes files that could possibly match, and files strays by rules you keep in a CSV. Duplicates are reported, not removed, unless you ask for them to be moved aside. It never deletes a thing.

Repair-EbookLibrary.ps1Download
<#
.SYNOPSIS
    Tidies an ebook and comic library: fixes wrong extensions, finds duplicate books, and files stray root files.
.DESCRIPTION
    Runs up to three passes over a library folder, in this order:
      Extensions  Reads the first bytes of every book file and renames it when the extension lies
                  (a PDF saved as .cbz, a ZIP comic saved as .cbr, a RAR saved as .cbz, and so on).
                  Files that turn out to be HTML error pages are flagged, never renamed.
      Duplicates  Groups files by size, hashes only the groups with more than one file (SHA256),
                  and reports every set of identical books. With -DuplicateAction Move, the extra
                  copies go to a quarantine folder. Nothing is ever deleted.
      Strays      Files sitting loose in the library root get filed: first by your own rules
                  (a CSV of Pattern,Folder), and anything left into an unsorted folder by format.
    Every rename and move goes through ShouldProcess, so -WhatIf shows the full plan.
.PARAMETER Path
    The root of the library.
.PARAMETER Task
    Which passes to run. Default: all three.
.PARAMETER DuplicateAction
    Report (default) just lists duplicates. Move sends every copy but one to -DuplicateFolder.
.PARAMETER DuplicateFolder
    Where Move sends extra copies. Default: _Duplicates in the library root. Relative subfolders are kept.
.PARAMETER RulePath
    A CSV with Pattern and Folder columns for filing root files, e.g. "*PowerShell*","Computing\PowerShell". First match wins.
.PARAMETER UnsortedFolder
    Where unmatched root files go, in a subfolder per format. Default: _Unsorted. Use -LeaveUnmatched to skip them instead.
.PARAMETER LeaveUnmatched
    Leave root files that no rule matches where they are.
.PARAMETER Extension
    Which file types count as books. Default: the usual ebook and comic formats.
.EXAMPLE
    .\Repair-EbookLibrary.ps1 -Path E:\Library -WhatIf
.EXAMPLE
    .\Repair-EbookLibrary.ps1 -Path E:\Library -Task Duplicates -DuplicateAction Move
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory, Position = 0)]
    [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })]
    [string]$Path,
    [ValidateSet('Extensions', 'Duplicates', 'Strays')][string[]]$Task = @('Extensions', 'Duplicates', 'Strays'),
    [ValidateSet('Report', 'Move')][string]$DuplicateAction = 'Report',
    [string]$DuplicateFolder = '_Duplicates',
    [string]$RulePath,
    [string]$UnsortedFolder = '_Unsorted',
    [switch]$LeaveUnmatched,
    [string[]]$Extension = @('.pdf', '.epub', '.mobi', '.azw', '.azw3', '.prc', '.djvu', '.cbz', '.cbr', '.cb7', '.fb2', '.txt')
)

$root = (Get-Item -LiteralPath $Path).FullName
if ($root.Length -gt [IO.Path]::GetPathRoot($root).Length) { $root = $root.TrimEnd('\', '/') }
$Extension = $Extension | ForEach-Object { if ($_.StartsWith('.')) { $_.ToLower() } else { ".$($_.ToLower())" } }
$workFolders = @($DuplicateFolder, $UnsortedFolder) | ForEach-Object { if ([IO.Path]::IsPathRooted($_)) { $_ } else { Join-Path $root $_ } }

function Get-BookFile {
    # Book files anywhere in the library, skipping the quarantine and unsorted folders.
    Get-ChildItem -LiteralPath $root -File -Recurse -ErrorAction SilentlyContinue | Where-Object {
        $f = $_
        $Extension -contains $f.Extension.ToLower() -and -not ($workFolders | Where-Object { $f.FullName.StartsWith($_ + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) })
    }
}

function Get-RealExtension([string]$File) {
    # Returns the extension the contents say it should have, 'html' for a saved web page, or $null if unknown.
    $buffer = [byte[]]::new(128)
    $stream = [IO.File]::OpenRead($File)
    try { $read = $stream.Read($buffer, 0, $buffer.Length) } finally { $stream.Dispose() }
    if ($read -lt 8) { return $null }
    $ascii = [Text.Encoding]::ASCII.GetString($buffer, 0, $read)

    if ($ascii.StartsWith('%PDF')) { return '.pdf' }
    if ($ascii.StartsWith('Rar!')) { return '.cbr' }
    if ($buffer[0] -eq 0x37 -and $buffer[1] -eq 0x7A -and $buffer[2] -eq 0xBC -and $buffer[3] -eq 0xAF) { return '.cb7' }
    if ($ascii.StartsWith('AT&TFORM')) { return '.djvu' }
    if ($read -ge 68 -and $ascii.Substring(60, 8) -eq 'BOOKMOBI') { return '.mobi' }
    if ($ascii.StartsWith("PK$([char]3)$([char]4)")) {
        # An EPUB's first entry is an uncompressed file called mimetype holding application/epub+zip.
        if ($ascii -match 'mimetypeapplication/epub\+zip') { return '.epub' }
        return '.cbz'
    }
    if ($ascii.TrimStart([char]0xEF, [char]0xBB, [char]0xBF, ' ', "`r", "`n", "`t") -match '^<(!doctype html|html|head)') { return 'html' }
    return $null
}

function Get-FreePath([string]$Folder, [string]$Name) {
    $base = [IO.Path]::GetFileNameWithoutExtension($Name)
    $ext = [IO.Path]::GetExtension($Name)
    $candidate = Join-Path $Folder $Name
    $n = 1
    while (Test-Path -LiteralPath $candidate) { $n++; $candidate = Join-Path $Folder "$base ($n)$ext" }
    $candidate
}

# Show four columns by default; NewPath and Path are still there for Select-Object and Export-Csv.
$display = [System.Management.Automation.PSPropertySet]::new('DefaultDisplayPropertySet', [string[]]@('Task', 'Name', 'Action', 'Detail'))
$standardMembers = [System.Management.Automation.PSMemberInfo[]]@($display)
function New-Result($TaskName, $File, $Action, $Detail, $NewPath) {
    [pscustomobject]@{ Task = $TaskName; Name = Split-Path -Path $File -Leaf; Action = $Action; Detail = $Detail; NewPath = $NewPath; Path = $File } |
        Add-Member -MemberType MemberSet -Name PSStandardMembers -Value $standardMembers -PassThru
}

# Files a duplicate move has claimed, so -WhatIf doesn't also plan to file them as strays.
$handled = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)

# ---- 1. Extensions ---------------------------------------------------------------------------
if ('Extensions' -in $Task) {
    $mobiFamily = '.mobi', '.azw', '.azw3', '.prc'
    $zipFamily = '.cbz', '.epub'   # a ZIP without the EPUB marker is fine as either
    foreach ($book in Get-BookFile) {
        try {
            $current = $book.Extension.ToLower()
            if ($current -eq '.txt' -or $current -eq '.fb2') { continue }   # plain text formats, nothing to sniff
            $real = Get-RealExtension $book.FullName
            if (-not $real) { continue }
            if ($real -eq 'html') { New-Result 'Extensions' $book.FullName 'LooksLikeHtml' 'Starts like a web page, probably a failed download. Not renamed.' $null; continue }
            if ($real -eq $current) { continue }
            if ($real -eq '.mobi' -and $current -in $mobiFamily) { continue }
            if ($real -eq '.cbz' -and $current -in $zipFamily) { continue }

            $target = Get-FreePath $book.DirectoryName ($book.BaseName + $real)
            if ($PSCmdlet.ShouldProcess($book.FullName, "Rename to $(Split-Path $target -Leaf) (contents are $real)")) {
                Rename-Item -LiteralPath $book.FullName -NewName (Split-Path $target -Leaf) -ErrorAction Stop
                New-Result 'Extensions' $book.FullName 'Renamed' "$current is really $real" $target
            }
            else { New-Result 'Extensions' $book.FullName 'WouldRename' "$current is really $real" $target }
        }
        catch {
            Write-Warning "$($book.FullName): $($_.Exception.Message)"
            New-Result 'Extensions' $book.FullName 'Failed' $_.Exception.Message $null
        }
    }
}

# ---- 2. Duplicates ---------------------------------------------------------------------------
if ('Duplicates' -in $Task) {
    # Only files that share a size can be identical, so most of the library never gets hashed.
    $sizeGroups = Get-BookFile | Where-Object Length -gt 0 | Group-Object Length | Where-Object Count -gt 1
    foreach ($group in $sizeGroups) {
        $hashed = foreach ($f in $group.Group) {
            try { [pscustomobject]@{ File = $f; Hash = (Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256 -ErrorAction Stop).Hash } }
            catch { Write-Warning "Couldn't hash $($f.FullName): $($_.Exception.Message)" }
        }
        foreach ($set in ($hashed | Group-Object Hash | Where-Object Count -gt 1)) {
            # Keep the copy that's already filed in a subfolder, then the shortest path, then alphabetical.
            $ordered = $set.Group | Sort-Object @{ Expression = { $_.File.DirectoryName -eq $root } }, @{ Expression = { $_.File.FullName.Length } }, @{ Expression = { $_.File.FullName } }
            $keeper = $ordered[0].File
            New-Result 'Duplicates' $keeper.FullName 'Keep' "$($set.Count) identical copies, SHA256 $($set.Name.Substring(0, 12))..." $null
            foreach ($copy in ($ordered | Select-Object -Skip 1)) {
                $file = $copy.File
                if ($DuplicateAction -eq 'Move') { $null = $handled.Add($file.FullName) }
                if ($DuplicateAction -eq 'Report') { New-Result 'Duplicates' $file.FullName 'Duplicate' "Same as $($keeper.FullName)" $null; continue }
                try {
                    $relativeDir = $file.DirectoryName.Substring($root.Length).TrimStart('\', '/')
                    $quarantine = Join-Path $workFolders[0] $relativeDir
                    $target = Get-FreePath $quarantine $file.Name
                    if ($PSCmdlet.ShouldProcess($file.FullName, "Move duplicate to $target")) {
                        $null = New-Item -ItemType Directory -Path $quarantine -Force -ErrorAction Stop
                        Move-Item -LiteralPath $file.FullName -Destination $target -ErrorAction Stop
                        New-Result 'Duplicates' $file.FullName 'MovedDuplicate' "Same as $($keeper.FullName)" $target
                    }
                    else { New-Result 'Duplicates' $file.FullName 'WouldMoveDuplicate' "Same as $($keeper.FullName)" $target }
                }
                catch {
                    Write-Warning "$($file.FullName): $($_.Exception.Message)"
                    New-Result 'Duplicates' $file.FullName 'Failed' $_.Exception.Message $null
                }
            }
        }
    }
}

# ---- 3. Strays -------------------------------------------------------------------------------
if ('Strays' -in $Task) {
    $rules = @()
    if ($RulePath) {
        try { $rules = @(Import-Csv -LiteralPath $RulePath -ErrorAction Stop | Where-Object { $_.Pattern -and $_.Folder }) }
        catch { throw "Couldn't read rules from '$RulePath': $($_.Exception.Message)" }
    }
    $strays = Get-ChildItem -LiteralPath $root -File | Where-Object { $Extension -contains $_.Extension.ToLower() -and -not $handled.Contains($_.FullName) }
    foreach ($file in $strays) {
        try {
            $folder = ($rules | Where-Object { $file.Name -like $_.Pattern } | Select-Object -First 1).Folder
            $how = 'Matched a rule'
            if (-not $folder) {
                if ($LeaveUnmatched) { New-Result 'Strays' $file.FullName 'LeftInRoot' 'No rule matched' $null; continue }
                $folder = Join-Path $UnsortedFolder $file.Extension.TrimStart('.').ToUpper()
                $how = 'No rule matched'
            }
            $destDir = if ([IO.Path]::IsPathRooted($folder)) { $folder } else { Join-Path $root $folder }
            $target = Get-FreePath $destDir $file.Name
            if ($PSCmdlet.ShouldProcess($file.FullName, "Move to $target")) {
                $null = New-Item -ItemType Directory -Path $destDir -Force -ErrorAction Stop
                Move-Item -LiteralPath $file.FullName -Destination $target -ErrorAction Stop
                New-Result 'Strays' $file.FullName 'Filed' $how $target
            }
            else { New-Result 'Strays' $file.FullName 'WouldFile' $how $target }
        }
        catch {
            Write-Warning "$($file.FullName): $($_.Exception.Message)"
            New-Result 'Strays' $file.FullName 'Failed' $_.Exception.Message $null
        }
    }
}

Parameters

ParameterTypeDefaultWhat it's for
-Pathstring—The root of the library. Required.
-Taskstring[]Extensions, Duplicates, StraysWhich passes to run. They always run in that order, so renamed files are hashed and filed under their new names.
-DuplicateActionstringReportReport just lists each set of duplicates. Move sends every copy but one to DuplicateFolder, keeping their subfolder paths.
-DuplicateFolderstring_DuplicatesQuarantine folder for extra copies, relative to Path unless you give a full path. It's skipped on later runs.
-RulePathstring—A CSV with Pattern and Folder columns for filing root files. Patterns are wildcards matched against the file name, first match wins.
-UnsortedFolderstring_UnsortedWhere root files that match no rule go, in a subfolder per format (PDF, EPUB, CBZ...). Also skipped on later runs.
-LeaveUnmatchedswitch—Leave root files that match no rule where they are.
-Extensionstring[].pdf .epub .mobi .azw .azw3 .prc .djvu .cbz .cbr .cb7 .fb2 .txtWhich file types count as books. Anything else in the library, like cover images or .opf files, is ignored.

Run it

See everything it would do, without changing a thing.

.\Repair-EbookLibrary.ps1 -Path E:\Library -WhatIf

Only fix extensions.

.\Repair-EbookLibrary.ps1 -Path E:\Library -Task Extensions

A duplicate report you can read in Excel, with full paths.

.\Repair-EbookLibrary.ps1 -Path E:\Library -Task Duplicates | Select-Object Action, Name, Detail, Path | Export-Csv .\duplicates.csv -NoTypeInformation

Move the extra copies into the quarantine folder once you've checked the report.

.\Repair-EbookLibrary.ps1 -Path E:\Library -Task Duplicates -DuplicateAction Move

File loose root files by your rules, and leave anything unmatched for later.

.\Repair-EbookLibrary.ps1 -Path E:\Library -Task Strays -RulePath .\library-rules.csv -LeaveUnmatched

What you'll see

Example outputvalues are illustrative
Task       Name                      Action         Detail
----       ----                      ------         ------
Extensions Space Rangers 012.cbz     Renamed        .cbz is really .pdf
Extensions Harbor Lights 03.cbr      Renamed        .cbr is really .cbz
Extensions Field Guide to Ferns.epub LooksLikeHtml  Starts like a web page, probably a failed download. Not renamed.
Extensions Contoso Style Guide.pdf   Renamed        .pdf is really .epub
Duplicates Networking Basics.pdf     Keep           3 identical copies, SHA256 6D8F6D620347...
Duplicates Networking Basics (1).pdf MovedDuplicate Same as E:\Library\Computing\Networking Basics.pdf
Duplicates nb-final.pdf              MovedDuplicate Same as E:\Library\Computing\Networking Basics.pdf
Strays     Scripting Handbook.epub   Filed          Matched a rule
Strays     Space Rangers 013.cbz     Filed          Matched a rule
Strays     scan_0004.pdf             Filed          No rule matched

How it works

  1. Read the first 128 bytes. Every file with a book extension is opened just long enough to read its signature: %PDF for PDF, Rar! for RAR, 7z plus its magic bytes for 7-Zip, AT&TFORM for DjVu, BOOKMOBI at offset 60 for the MOBI family, and PK for ZIP. A ZIP whose first entry is the uncompressed mimetype file saying application/epub+zip is an EPUB; any other ZIP is a CBZ. RAR becomes .cbr and 7z becomes .cb7.

  2. Rename only when it's sure. Files whose signature isn't recognized, and plain-text formats like .txt and .fb2, are left alone. If the corrected name is already taken, the file gets (2) on the end rather than overwriting anything.

  3. Size first, then hash. Files can only be identical if they're the same size, so the script groups by length and runs Get-FileHash -Algorithm SHA256 only on groups with more than one file. On a typical library that skips the vast majority of files.

  4. Pick a keeper per set. Filed beats loose-in-the-root, shorter path beats longer, then alphabetical. With -DuplicateAction Move, the others go to the quarantine folder under the same relative path they came from, so you can see where each one lived.

  5. File the strays. Root files are matched against your rules in order. A rules file looks like this:

    Pattern,Folder
    *PowerShell*,Computing\PowerShell
    Space Rangers*,Comics\Space Rangers
    *Cookbook*,Cooking
    

    Anything unmatched goes to _Unsorted\PDF, _Unsorted\EPUB and so on, unless you use -LeaveUnmatched.

Every rename and move goes through ShouldProcess, and every file comes back as an object, including NewPath and the full Path for anything you want to export.

Take it further

  • Standardize your comics next. Once the .cbr files that were really ZIPs are fixed, convert the rest from CBR to CBZ so the whole collection is one format.
  • Double-check a suspicious pair. For two files you're not sure about, compare them by hash directly before clearing out the quarantine folder.
  • Rescan your library server. Calibre, Kavita and Komga all cache what they found last time. Trigger a scan after a run so renamed and moved files show up in the right place.

Things that'll trip you up

  • A ZIP isn't always a comic. A ZIP without the EPUB marker is renamed to .cbz, because in a book library that's almost always what it is. If you keep other ZIP-based files under book extensions, check the WouldRename lines from a -WhatIf run first. And a file already named .epub is never renamed just because the marker is missing; plenty of sloppy but readable EPUBs are built that way.
  • MOBI, AZW and AZW3 look the same inside. They all carry the BOOKMOBI marker, so the script treats them as one family and leaves those extensions alone. It only renames a MOBI-family file when it's hiding under something else entirely, like .pdf.
  • Duplicates means byte-for-byte identical. The same book from two sources, a different edition, or an EPUB that a library app has re-saved will all have different hashes. This catches true copies, which is usually most of them, but it isn't a librarian.
  • Know which copy is kept. The keeper is the copy that's already filed in a subfolder, then the one with the shortest path, then alphabetical. Read the Report output before you run with -DuplicateAction Move, and nothing is deleted either way, so a wrong pick is one drag back out of the quarantine folder.
  • HTML files are flagged, never renamed. A book that starts with <html> is usually a login or error page that got saved instead of the real download. The script lists it as LooksLikeHtml so you can go get it again.