<# .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 } } }