Created
June 23, 2025 22:13
-
-
Save Solessfir/cf0a22b30c6459f9d7436f6678bb8ad4 to your computer and use it in GitHub Desktop.
PowerShell script to batch convert FLAC to MP3 using FFmpeg
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
param( | |
[Parameter(Position=0)] | |
[string]$RootPath = $PWD, | |
[Alias("?")] | |
[switch]$Help, | |
[switch]$SkipExisting, | |
[string]$Bitrate = "320k", | |
[Alias("g")] | |
[string]$Genre | |
) | |
function Show-Usage { | |
Write-Host "FLAC to MP3 Converter" -ForegroundColor Cyan | |
Write-Host "=====================" | |
Write-Host "Converts FLAC albums to MP3 tracks using CUE sheets and embeds album art" | |
Write-Host "" | |
Write-Host "Usage:" | |
Write-Host " .\$($MyInvocation.MyCommand.Name) [<RootPath>] [-SkipExisting] [-Bitrate <value>] [-Genre <genre>] [-Help]" | |
Write-Host "" | |
Write-Host "Parameters:" | |
Write-Host " <RootPath> : Path to discography or album folder (default: current directory)" | |
Write-Host " -SkipExisting : Skip conversion if MP3 file already exists" | |
Write-Host " -Bitrate : MP3 bitrate in kbps (default: 320k)" | |
Write-Host " -Genre (-g) : Override genre metadata for all tracks" | |
Write-Host " -Help (-?) : Show this help message" | |
Write-Host "" | |
Write-Host "Example:" | |
Write-Host " .\$($MyInvocation.MyCommand.Name) `"C:\Music\My Band`" -SkipExisting -g Metal" | |
exit 0 | |
} | |
if ($Help) { Show-Usage } | |
# Validate bitrate format | |
if (-not ($Bitrate -match "^\d+k$")) { | |
Write-Host "Invalid bitrate format. Use format like '320k', '256k', etc." -ForegroundColor Red | |
exit 1 | |
} | |
# Check FFmpeg availability | |
if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { | |
Write-Host "FFmpeg is not installed or not in system PATH." -ForegroundColor Red | |
Write-Host "Download from: https://ffmpeg.org/download.html" -ForegroundColor Yellow | |
Write-Host "Or install using Winget: winget install --id Gyan.FFmpeg" -ForegroundColor Yellow | |
exit 1 | |
} | |
# Find FLAC files | |
$flacFiles = Get-ChildItem -Path $RootPath -Filter *.flac -Recurse -File -ErrorAction SilentlyContinue | |
if (-not $flacFiles) { | |
Write-Host "No FLAC files found in: $RootPath" -ForegroundColor Yellow | |
exit 1 | |
} | |
Write-Host "`nStarting conversion..." -ForegroundColor Green | |
Write-Host "Found $($flacFiles.Count) albums to process" -ForegroundColor Cyan | |
Write-Host "Using bitrate: $Bitrate" -ForegroundColor Cyan | |
$failedConversions = @() | |
$albumCount = 0 | |
$successfulAlbums = 0 | |
foreach ($flac in $flacFiles) { | |
$albumCount++ | |
$cueFile = [System.IO.Path]::ChangeExtension($flac.FullName, ".cue") | |
if (-not (Test-Path -Path $cueFile -PathType Leaf)) { | |
Write-Host "[$albumCount/$($flacFiles.Count)] Skipping: $($flac.Name)" -ForegroundColor Yellow | |
Write-Host " CUE file not found" -ForegroundColor Red | |
$failedConversions += "ALBUM: $($flac.Name) | REASON: CUE file missing" | |
continue | |
} | |
# Album art search | |
$albumArt = $null | |
$imageExtensions = @('*.jpg', '*.jpeg', '*.png') | |
$preferredNames = @('folder', 'cover', 'front', 'albumart') | |
# Check for preferred filenames (case-insensitive) | |
foreach ($name in $preferredNames) { | |
$foundArt = Get-ChildItem -Path $flac.DirectoryName -File -ErrorAction SilentlyContinue | | |
Where-Object { $_.BaseName -like $name -and $_.Extension -match '^\.(jpg|jpeg|png)$' } | | |
Select-Object -First 1 | |
if ($foundArt) { | |
$albumArt = $foundArt | |
break | |
} | |
} | |
# If no preferred art found, get any image | |
if (-not $albumArt) { | |
$albumArt = Get-ChildItem -Path $flac.DirectoryName -Include $imageExtensions -File -ErrorAction SilentlyContinue | | |
Select-Object -First 1 | |
} | |
# Parse CUE file with UTF-8 encoding | |
$globalMetadata = @{ | |
PERFORMER = "Unknown Artist" | |
TITLE = $flac.BaseName | |
DATE = "" | |
GENRE = "" | |
COMMENT = "" | |
} | |
$tracks = @() | |
$currentTrack = $null | |
Get-Content $cueFile -Encoding UTF8 | ForEach-Object { | |
$line = $_.Trim() | |
# Handle REM commands with different formats | |
if ($line -match '^REM (\w+) (.+)$') { | |
$key = $matches[1].ToUpper() | |
$value = $matches[2].Trim() | |
# Remove quotes if present | |
if ($value.StartsWith('"') -and $value.EndsWith('"')) { | |
$value = $value.Substring(1, $value.Length - 2) | |
} | |
$globalMetadata[$key] = $value | |
} | |
# Handle PERFORMER with or without quotes | |
elseif ($line -match '^PERFORMER (?:")?(.+?)(?:")?$') { | |
$performer = $matches[1].Trim() | |
if ($currentTrack) { | |
$currentTrack.Performer = $performer | |
} else { | |
$globalMetadata["PERFORMER"] = $performer | |
} | |
} | |
# Handle TITLE with or without quotes | |
elseif ($line -match '^TITLE (?:")?(.+?)(?:")?$') { | |
$title = $matches[1].Trim() | |
if ($currentTrack) { | |
$currentTrack.Title = $title | |
} else { | |
$globalMetadata["TITLE"] = $title | |
} | |
} | |
# Handle TRACK definition | |
elseif ($line -match '^TRACK (\d+) AUDIO$') { | |
if ($currentTrack) { $tracks += $currentTrack } | |
$currentTrack = [ordered]@{ | |
Number = $matches[1] | |
Title = "Unknown Title" | |
Performer = $globalMetadata["PERFORMER"] | |
StartTime = $null | |
IndexFound = $false | |
} | |
} | |
# Handle INDEX timestamps (with proper frame conversion) | |
elseif ($line -match '^\s*INDEX 01 (\d+):(\d+):(\d+)') { | |
if (-not $currentTrack) { | |
Write-Host " [WARNING] INDEX without TRACK: $line" -ForegroundColor Yellow | |
continue | |
} | |
$min = [int]$matches[1] | |
$sec = [int]$matches[2] | |
$frames = [int]$matches[3] | |
# Convert frames to seconds (1 frame = 1/75 second) | |
$startSec = $min * 60 + $sec + ($frames / 75.0) | |
$currentTrack.StartTime = $startSec | |
$currentTrack.IndexFound = $true | |
} | |
# Skip FILE lines | |
elseif ($line -match '^FILE ') { | |
# Ignore file references | |
} | |
# Handle other commands | |
else { | |
# Ignore unrecognized lines | |
} | |
} | |
if ($currentTrack) { $tracks += $currentTrack } | |
# Validate tracks | |
$validTracks = @() | |
foreach ($track in $tracks) { | |
if (-not $track.IndexFound) { | |
Write-Host " [WARNING] No INDEX 01 found for track $($track.Number)" -ForegroundColor Yellow | |
$failedConversions += "ALBUM: $($globalMetadata["TITLE"]) | TRACK: $($track.Number) | REASON: Missing INDEX 01" | |
} elseif ($null -eq $track.StartTime) { | |
Write-Host " [WARNING] Start time not set for track $($track.Number)" -ForegroundColor Yellow | |
$failedConversions += "ALBUM: $($globalMetadata["TITLE"]) | TRACK: $($track.Number) | REASON: Start time not set" | |
} else { | |
$validTracks += $track | |
} | |
} | |
if ($validTracks.Count -eq 0) { | |
Write-Host "[$albumCount/$($flacFiles.Count)] Skipping: $($globalMetadata["TITLE"])" -ForegroundColor Red | |
Write-Host " No valid tracks found in CUE file" -ForegroundColor Red | |
$failedConversions += "ALBUM: $($globalMetadata["TITLE"]) | REASON: No valid tracks" | |
continue | |
} | |
# Create Year - Album output directory | |
$year = if ($globalMetadata["DATE"]) { $globalMetadata["DATE"] } else { "UnknownYear" } | |
$cleanAlbumTitle = $globalMetadata["TITLE"] -replace '[\\/:*?"<>|]', '_' | |
$albumFolderName = "$year - $cleanAlbumTitle" | |
$outputDir = Join-Path $flac.DirectoryName $albumFolderName | |
if (-not (Test-Path -Path $outputDir)) { | |
New-Item -ItemType Directory -Path $outputDir | Out-Null | |
} | |
# Process tracks | |
Write-Host "[$albumCount/$($flacFiles.Count)] Processing Album: $($globalMetadata["TITLE"])" -ForegroundColor Magenta | |
Write-Host "Year: $year" | |
Write-Host "Artist: $($globalMetadata["PERFORMER"])" | |
Write-Host "Tracks: $($validTracks.Count)/$($tracks.Count)" | |
Write-Host "Output: $outputDir`n" | |
if ($albumArt) { | |
Write-Host "Found album art $($albumArt.Name) to embed`n" -ForegroundColor Cyan | |
} | |
$albumSuccess = $true | |
$skippedTracks = 0 | |
for ($i = 0; $i -lt $validTracks.Count; $i++) { | |
$track = $validTracks[$i] | |
$start = $track.StartTime | |
$end = if ($i -lt $validTracks.Count - 1) { $validTracks[$i+1].StartTime } else { $null } | |
# Calculate duration (next track start - current start) | |
$duration = if ($end) { $end - $start } else { $null } | |
# Format with invariant culture | |
$startStr = $start.ToString("0.000", [System.Globalization.CultureInfo]::InvariantCulture) | |
$durationStr = if ($duration) { $duration.ToString("0.000", [System.Globalization.CultureInfo]::InvariantCulture) } | |
# Clean track title and pad track number | |
$cleanTitle = $track.Title -replace '[\\/:*?"<>|]', '_' | |
$trackNumberPadded = $track.Number.PadLeft(2, '0') | |
$outputFile = Join-Path $outputDir "$trackNumberPadded. $cleanTitle.mp3" | |
# Skip existing files | |
if ($SkipExisting -and (Test-Path -Path $outputFile)) { | |
Write-Host " [SKIPPED] Track $($track.Number): $($track.Title)" | |
$skippedTracks++ | |
continue | |
} | |
# Build FFmpeg command arguments | |
$ffmpegArgs = @() | |
# Add audio input with seek position | |
$ffmpegArgs += @( | |
"-ss", $startStr, | |
"-i", "`"$($flac.FullName)`"" | |
) | |
# Add album art input | |
if ($albumArt) { | |
$ffmpegArgs += @("-i", "`"$($albumArt.FullName)`"") | |
} | |
# Add mapping and codec options | |
$ffmpegArgs += @( | |
"-map", "0:a" # Audio from first input | |
) | |
if ($albumArt) { | |
$ffmpegArgs += @( | |
"-map", "1:v" # Video from second input | |
"-c:v", "copy" | |
"-id3v2_version", "3" | |
"-metadata:s:v", "title=`"Album cover`"" | |
"-metadata:s:v", "comment=`"Cover (front)`"" | |
"-disposition:v", "attached_pic" | |
) | |
} else { | |
$ffmpegArgs += @("-id3v2_version", "3") | |
} | |
$ffmpegArgs += @( | |
"-c:a", "libmp3lame" | |
"-b:a", $Bitrate | |
"-metadata", "title=`"$($track.Title)`"" | |
"-metadata", "artist=`"$($track.Performer)`"" | |
"-metadata", "album_artist=`"$($globalMetadata["PERFORMER"])`"" | |
"-metadata", "album=`"$($globalMetadata["TITLE"])`"" | |
"-metadata", "track=`"$($track.Number)/$($validTracks.Count)`"" | |
) | |
# Add duration parameter | |
if ($durationStr) { | |
$ffmpegArgs += @("-t", $durationStr) | |
} | |
# Apply genre override if specified | |
$genreValue = if ($Genre) { $Genre } else { $globalMetadata["GENRE"] } | |
if ($genreValue) { | |
$ffmpegArgs += "-metadata", "GENRE=`"$genreValue`"" | |
} | |
foreach ($key in @("DATE", "COMMENT")) { | |
if ($globalMetadata[$key]) { | |
$ffmpegArgs += "-metadata", "$key=`"$($globalMetadata[$key])`"" | |
} | |
} | |
$ffmpegArgs += "`"$outputFile`"" | |
# Execute conversion | |
Write-Host "Converting track $($track.Number): $($track.Title)" | |
try { | |
# Build and display command string | |
$ffmpegCommand = "ffmpeg " + ($ffmpegArgs -join " ") | |
Write-Host "Executing: $ffmpegCommand`n" -ForegroundColor DarkGray | |
# Run command using cmd | |
$output = cmd /c $ffmpegCommand 2>&1 | |
if ($LASTEXITCODE -ne 0) { | |
Write-Host "[FFMPEG ERROR] Exit code: $LASTEXITCODE" -ForegroundColor Red | |
Write-Host "FFmpeg output: $($output | Out-String)`n" -ForegroundColor Red | |
throw "FFmpeg conversion failed with exit code $LASTEXITCODE" | |
} | |
} catch { | |
Write-Host "[ERROR] Conversion failed: $($_.Exception.Message)" -ForegroundColor Red | |
$failedConversions += "ALBUM: $($globalMetadata["TITLE"]) | TRACK: $($track.Number) | REASON: $($_.Exception.Message)" | |
$albumSuccess = $false | |
# Skip remaining tracks in this album | |
Write-Host "Skipping remaining tracks in this album due to error" -ForegroundColor Yellow | |
break | |
} | |
} | |
if ($albumSuccess) { | |
$successfulAlbums++ | |
if ($skippedTracks -gt 0) { | |
Write-Host "Success: $($validTracks.Count - $skippedTracks) tracks converted, $skippedTracks skipped" -ForegroundColor Green | |
} | |
} | |
} | |
# Final report | |
Write-Host "`nConversion complete!" -ForegroundColor Green | |
Write-Host "=================================" | |
Write-Host "Albums processed: $albumCount" -ForegroundColor Cyan | |
Write-Host "Successfully converted: $successfulAlbums" -ForegroundColor Green | |
if ($albumCount - $successfulAlbums -gt 0) { | |
Write-Host "Partially/Fully failed: $($albumCount - $successfulAlbums)" -ForegroundColor Red | |
} | |
if ($failedConversions.Count -gt 0) { | |
Write-Host "`nFailed conversions summary:" -ForegroundColor Red | |
Write-Host "==========================" | |
$failedConversions | Group-Object { $_ -split '\|' | Select-Object -First 1 } | ForEach-Object { | |
Write-Host "`n$($_.Count) errors in category: $($_.Name)" -ForegroundColor Yellow | |
$_.Group | ForEach-Object { | |
$parts = $_ -split '\|' | |
Write-Host " - $($parts[1].Trim())" -ForegroundColor DarkYellow | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment