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.
- 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.
<#
.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
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-Path | string | — | The root of the library. Required. |
-Task | string[] | Extensions, Duplicates, Strays | Which passes to run. They always run in that order, so renamed files are hashed and filed under their new names. |
-DuplicateAction | string | Report | Report just lists each set of duplicates. Move sends every copy but one to DuplicateFolder, keeping their subfolder paths. |
-DuplicateFolder | string | _Duplicates | Quarantine folder for extra copies, relative to Path unless you give a full path. It's skipped on later runs. |
-RulePath | string | — | A CSV with Pattern and Folder columns for filing root files. Patterns are wildcards matched against the file name, first match wins. |
-UnsortedFolder | string | _Unsorted | Where root files that match no rule go, in a subfolder per format (PDF, EPUB, CBZ...). Also skipped on later runs. |
-LeaveUnmatched | switch | — | Leave root files that match no rule where they are. |
-Extension | string[] | .pdf .epub .mobi .azw .azw3 .prc .djvu .cbz .cbr .cb7 .fb2 .txt | Which 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 -WhatIfOnly fix extensions.
.\Repair-EbookLibrary.ps1 -Path E:\Library -Task ExtensionsA 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 -NoTypeInformationMove the extra copies into the quarantine folder once you've checked the report.
.\Repair-EbookLibrary.ps1 -Path E:\Library -Task Duplicates -DuplicateAction MoveFile loose root files by your rules, and leave anything unmatched for later.
.\Repair-EbookLibrary.ps1 -Path E:\Library -Task Strays -RulePath .\library-rules.csv -LeaveUnmatchedWhat you'll see
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
-
Read the first 128 bytes. Every file with a book extension is opened just long enough to read its signature:
%PDFfor PDF,Rar!for RAR,7zplus its magic bytes for 7-Zip,AT&TFORMfor DjVu,BOOKMOBIat offset 60 for the MOBI family, andPKfor ZIP. A ZIP whose first entry is the uncompressedmimetypefile sayingapplication/epub+zipis an EPUB; any other ZIP is a CBZ. RAR becomes.cbrand 7z becomes.cb7. -
Rename only when it's sure. Files whose signature isn't recognized, and plain-text formats like
.txtand.fb2, are left alone. If the corrected name is already taken, the file gets(2)on the end rather than overwriting anything. -
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 SHA256only on groups with more than one file. On a typical library that skips the vast majority of files. -
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. -
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*,CookingAnything unmatched goes to
_Unsorted\PDF,_Unsorted\EPUBand 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.