Created
May 5, 2026 08:22
-
-
Save qubitrenegade/d2d3e898bfaf28bd6a1021b62ba2e953 to your computer and use it in GitHub Desktop.
Revised version of: https://www.reddit.com/r/ffmpeg/comments/1t48nqu/an_automatic_way_to_convert_multiple_audiobook/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| Convert one folder of MP3 chapters per audiobook into one M4B file per folder. | |
| .DESCRIPTION | |
| Put this script next to your audiobook folders. Each audiobook should be its own folder. | |
| Each folder should contain numbered MP3 files and, optionally, cover.jpg, cover.jpeg, or cover.png. | |
| Example: | |
| Audiobooks | |
| convert.ps1 | |
| 02 - Chamber of Secrets | |
| 001 - The Worst Birthday.mp3 | |
| 002 - Dobby's Warning.mp3 | |
| 003 - The Burrow.mp3 | |
| cover.jpg | |
| The script creates this output next to the audiobook folders: | |
| 02 - Chamber of Secrets.m4b | |
| This script intentionally refuses unnumbered files by default. That is a safety choice. | |
| A script cannot know the correct chapter order from chapter names alone. | |
| Use -AllowUnnumbered only when you are sure filename sorting matches the correct playback order. | |
| .NOTES | |
| This version does not require PowerShell 7. It avoids PowerShell 7 only syntax and should work | |
| in Windows PowerShell 5.1 as well. | |
| .EXAMPLE | |
| powershell.exe -ExecutionPolicy Bypass -File .\convert.ps1 | |
| .EXAMPLE | |
| pwsh.exe -ExecutionPolicy Bypass -File .\convert.ps1 | |
| .EXAMPLE | |
| powershell.exe -ExecutionPolicy Bypass -File .\convert.ps1 -Overwrite -AudioBitrate 192k | |
| .EXAMPLE | |
| powershell.exe -ExecutionPolicy Bypass -File .\convert.ps1 -RootPath "D:\Audiobooks" | |
| #> | |
| param( | |
| # By default, process the folder where this script lives. That makes direct -File execution safer. | |
| [string]$RootPath = $PSScriptRoot, | |
| # Without this switch, the script refuses to replace an existing M4B file. | |
| [switch]$Overwrite, | |
| # Without this switch, MP3 files must start with a number like 001, 002, 003. | |
| [switch]$AllowUnnumbered, | |
| # 128k AAC is a practical default for spoken-word audiobooks. | |
| # Use 192k or higher if the book has music, full-cast production, or you simply want larger files. | |
| [ValidateSet("64k", "96k", "128k", "160k", "192k", "256k", "320k")] | |
| [string]$AudioBitrate = "128k" | |
| ) | |
| $ErrorActionPreference = "Stop" | |
| if ([string]::IsNullOrWhiteSpace($RootPath)) { | |
| $RootPath = (Get-Location).Path | |
| } | |
| $RootPath = (Resolve-Path -LiteralPath $RootPath).Path | |
| foreach ($tool in @("ffmpeg", "ffprobe")) { | |
| if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { | |
| throw "$tool was not found in PATH. Install ffmpeg and make sure ffmpeg.exe and ffprobe.exe are available." | |
| } | |
| } | |
| function Invoke-NativeCommandCapture { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [string]$FilePath, | |
| [Parameter(Mandatory = $true)] | |
| [string[]]$ArgumentList | |
| ) | |
| # PowerShell errors and native executable errors are different things. | |
| # A failed ffmpeg or ffprobe command does not automatically behave like a failed PowerShell command. | |
| # This wrapper captures output and turns a non-zero exit code into a real exception. | |
| $output = & $FilePath @ArgumentList 2>&1 | |
| $exitCode = $LASTEXITCODE | |
| if ($exitCode -ne 0) { | |
| $message = ($output | Out-String).Trim() | |
| throw "$FilePath failed with exit code $exitCode.`n$message" | |
| } | |
| return $output | |
| } | |
| function Invoke-NativeCommandShowOutput { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [string]$FilePath, | |
| [Parameter(Mandatory = $true)] | |
| [string[]]$ArgumentList | |
| ) | |
| # ffmpeg can take a while, so this wrapper lets ffmpeg print progress normally. | |
| # After ffmpeg exits, we still check the exit code so failures are not hidden. | |
| & $FilePath @ArgumentList | |
| $exitCode = $LASTEXITCODE | |
| if ($exitCode -ne 0) { | |
| throw "$FilePath failed with exit code $exitCode." | |
| } | |
| } | |
| function Get-NaturalSortKey { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Text | |
| ) | |
| # Normal filename sorting can put 10 before 2. | |
| # Natural sorting pads digit groups so 2 sorts before 10. | |
| # This helps with names like "1 - Intro.mp3", "2 - Chapter.mp3", "10 - Chapter.mp3". | |
| $parts = [regex]::Split($Text.ToLowerInvariant(), '(\d+)') | |
| $keyParts = foreach ($part in $parts) { | |
| if ($part -match '^\d+$') { | |
| $part.PadLeft(20, '0') | |
| } else { | |
| $part | |
| } | |
| } | |
| return ($keyParts -join '') | |
| } | |
| function ConvertTo-FfconcatPath { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Path | |
| ) | |
| # ffmpeg's concat demuxer reads a small text file containing one file path per line. | |
| # Forward slashes are friendlier to ffmpeg on Windows. | |
| # Apostrophes need escaping because each path is wrapped in single quotes. | |
| $normalized = $Path.Replace('\', '/') | |
| $normalized = $normalized.Replace("'", "'\''") | |
| return $normalized | |
| } | |
| function Escape-FfmetadataValue { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Value | |
| ) | |
| # ffmetadata treats some characters as syntax. | |
| # Escaping them keeps chapter titles from accidentally breaking the metadata file. | |
| $escaped = $Value.Replace('\', '\\') | |
| $escaped = $escaped.Replace("`r", ' ') | |
| $escaped = $escaped.Replace("`n", ' ') | |
| $escaped = $escaped.Replace(';', '\;') | |
| $escaped = $escaped.Replace('#', '\#') | |
| $escaped = $escaped.Replace('=', '\=') | |
| return $escaped.Trim() | |
| } | |
| function Get-ChapterTitle { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [System.IO.FileInfo]$File, | |
| [Parameter(Mandatory = $true)] | |
| [int]$Index | |
| ) | |
| # The file number controls order, but it usually does not need to appear twice in the chapter title. | |
| # This turns "001 - Owl Post.mp3" into "Owl Post". | |
| $title = $File.BaseName -replace '^\s*\d+[\s\.\-_\)]*', '' | |
| $title = $title.Trim() | |
| if ([string]::IsNullOrWhiteSpace($title)) { | |
| $title = $File.BaseName | |
| } | |
| $chapterNumber = "{0:D3}" -f $Index | |
| return "Chapter $chapterNumber: $title" | |
| } | |
| function Get-AudioDurationMilliseconds { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [string]$FilePath | |
| ) | |
| # Chapter metadata needs start and end times. | |
| # ffprobe asks the audio file for its duration, then we convert seconds to milliseconds. | |
| $output = Invoke-NativeCommandCapture -FilePath "ffprobe" -ArgumentList @( | |
| "-v", "error", | |
| "-show_entries", "format=duration", | |
| "-of", "default=noprint_wrappers=1:nokey=1", | |
| $FilePath | |
| ) | |
| $durationText = (($output | Select-Object -First 1) -as [string]).Trim() | |
| [double]$durationSeconds = 0 | |
| $parsed = [double]::TryParse( | |
| $durationText, | |
| [System.Globalization.NumberStyles]::Float, | |
| [System.Globalization.CultureInfo]::InvariantCulture, | |
| [ref]$durationSeconds | |
| ) | |
| if (-not $parsed -or $durationSeconds -le 0) { | |
| throw "Could not read a valid duration from: $FilePath" | |
| } | |
| return [int64][math]::Round($durationSeconds * 1000) | |
| } | |
| function Find-CoverImage { | |
| param( | |
| [Parameter(Mandatory = $true)] | |
| [string]$FolderPath | |
| ) | |
| # Only use intentional cover filenames. | |
| # This avoids accidentally attaching a random image that happens to be in the folder. | |
| foreach ($name in @("cover.jpg", "cover.jpeg", "cover.png")) { | |
| $candidate = Join-Path $FolderPath $name | |
| if (Test-Path -LiteralPath $candidate -PathType Leaf) { | |
| return Get-Item -LiteralPath $candidate | |
| } | |
| } | |
| return $null | |
| } | |
| $folders = @(Get-ChildItem -LiteralPath $RootPath -Directory | Sort-Object Name) | |
| if ($folders.Count -eq 0) { | |
| Write-Warning "No audiobook folders were found in: $RootPath" | |
| exit 0 | |
| } | |
| Write-Host "Root folder: $RootPath" | |
| Write-Host "Audio bitrate: $AudioBitrate" | |
| Write-Host "Overwrite existing output: $Overwrite" | |
| foreach ($folder in $folders) { | |
| Write-Host "" | |
| Write-Host "===== Processing: $($folder.Name) =====" | |
| $tempDir = $null | |
| try { | |
| $files = @( | |
| Get-ChildItem -LiteralPath $folder.FullName -File -Filter "*.mp3" | | |
| Sort-Object @{ Expression = { Get-NaturalSortKey -Text $_.Name } } | |
| ) | |
| if ($files.Count -eq 0) { | |
| Write-Host "No MP3 files found. Skipping." | |
| continue | |
| } | |
| $unnumbered = @($files | Where-Object { $_.BaseName -notmatch '^\s*\d+' }) | |
| if ($unnumbered.Count -gt 0 -and -not $AllowUnnumbered) { | |
| Write-Warning "Skipping '$($folder.Name)' because one or more MP3 files do not start with a number." | |
| Write-Warning "Rename files like 001 - Chapter Name.mp3, or run again with -AllowUnnumbered if you are sure." | |
| foreach ($file in ($unnumbered | Select-Object -First 5)) { | |
| Write-Warning "Unnumbered file: $($file.Name)" | |
| } | |
| continue | |
| } | |
| $outputFile = Join-Path $RootPath ($folder.Name + ".m4b") | |
| if ((Test-Path -LiteralPath $outputFile -PathType Leaf) -and -not $Overwrite) { | |
| Write-Warning "Output already exists and -Overwrite was not used. Skipping: $outputFile" | |
| continue | |
| } | |
| Write-Host "MP3 files found: $($files.Count)" | |
| Write-Host "First file: $($files[0].Name)" | |
| Write-Host "Last file: $($files[$files.Count - 1].Name)" | |
| $cover = Find-CoverImage -FolderPath $folder.FullName | |
| if ($cover) { | |
| Write-Host "Cover image: $($cover.Name)" | |
| } else { | |
| Write-Host "Cover image: none" | |
| } | |
| # Keep temporary files outside the audiobook folder so we never overwrite or delete a user's own files. | |
| $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("m4b-convert-" + [guid]::NewGuid().ToString("N")) | |
| New-Item -ItemType Directory -Path $tempDir | Out-Null | |
| $concatListPath = Join-Path $tempDir "concat.txt" | |
| $metadataPath = Join-Path $tempDir "metadata.txt" | |
| $concatLines = foreach ($file in $files) { | |
| $escapedPath = ConvertTo-FfconcatPath -Path $file.FullName | |
| "file '$escapedPath'" | |
| } | |
| $metadataLines = New-Object System.Collections.Generic.List[string] | |
| [void]$metadataLines.Add(";FFMETADATA1") | |
| [void]$metadataLines.Add("title=$(Escape-FfmetadataValue -Value $folder.Name)") | |
| [int64]$startMs = 0 | |
| [int]$chapterIndex = 1 | |
| foreach ($file in $files) { | |
| $durationMs = Get-AudioDurationMilliseconds -FilePath $file.FullName | |
| $endMs = $startMs + $durationMs | |
| $chapterTitle = Escape-FfmetadataValue -Value (Get-ChapterTitle -File $file -Index $chapterIndex) | |
| [void]$metadataLines.Add("[CHAPTER]") | |
| [void]$metadataLines.Add("TIMEBASE=1/1000") | |
| [void]$metadataLines.Add("START=$startMs") | |
| [void]$metadataLines.Add("END=$endMs") | |
| [void]$metadataLines.Add("title=$chapterTitle") | |
| $startMs = $endMs | |
| $chapterIndex++ | |
| } | |
| # UTF-8 without BOM is a good default for ffmpeg metadata and concat text files. | |
| # This helps filenames and chapter names with accents or non-English characters. | |
| $utf8NoBom = New-Object System.Text.UTF8Encoding($false) | |
| [System.IO.File]::WriteAllLines($concatListPath, [string[]]$concatLines, $utf8NoBom) | |
| [System.IO.File]::WriteAllLines($metadataPath, $metadataLines.ToArray(), $utf8NoBom) | |
| $ffmpegArgs = @("-hide_banner") | |
| if ($Overwrite) { | |
| $ffmpegArgs += "-y" | |
| } else { | |
| $ffmpegArgs += "-n" | |
| } | |
| $ffmpegArgs += @( | |
| "-f", "concat", | |
| "-safe", "0", | |
| "-i", $concatListPath, | |
| "-f", "ffmetadata", | |
| "-i", $metadataPath | |
| ) | |
| if ($cover) { | |
| $ffmpegArgs += @("-i", $cover.FullName) | |
| $ffmpegArgs += @( | |
| "-map", "0:a:0", | |
| "-map_metadata", "1", | |
| "-map_chapters", "1", | |
| "-map", "2:v:0", | |
| "-c:a", "aac", | |
| "-b:a", $AudioBitrate, | |
| "-c:v", "copy", | |
| "-disposition:v:0", "attached_pic", | |
| "-metadata:s:v", "title=Cover", | |
| "-metadata:s:v", "comment=Cover (front)", | |
| "-metadata", "title=$($folder.Name)", | |
| "-movflags", "+faststart", | |
| $outputFile | |
| ) | |
| } else { | |
| $ffmpegArgs += @( | |
| "-map", "0:a:0", | |
| "-map_metadata", "1", | |
| "-map_chapters", "1", | |
| "-c:a", "aac", | |
| "-b:a", $AudioBitrate, | |
| "-metadata", "title=$($folder.Name)", | |
| "-movflags", "+faststart", | |
| $outputFile | |
| ) | |
| } | |
| Invoke-NativeCommandShowOutput -FilePath "ffmpeg" -ArgumentList $ffmpegArgs | |
| Write-Host "Created: $outputFile" | |
| } | |
| catch { | |
| Write-Warning "Failed to process '$($folder.Name)': $($_.Exception.Message)" | |
| } | |
| finally { | |
| if ($tempDir -and (Test-Path -LiteralPath $tempDir)) { | |
| Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue | |
| } | |
| } | |
| } | |
| Write-Host "" | |
| Write-Host "Done." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment