Skip to content

Instantly share code, notes, and snippets.

@Solessfir
Created June 23, 2025 22:13
Show Gist options
  • Save Solessfir/cf0a22b30c6459f9d7436f6678bb8ad4 to your computer and use it in GitHub Desktop.
Save Solessfir/cf0a22b30c6459f9d7436f6678bb8ad4 to your computer and use it in GitHub Desktop.
PowerShell script to batch convert FLAC to MP3 using FFmpeg
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