Files
RelationshipsJukeBox/GeneratePlaylist.ps1
T
2026-07-05 08:26:06 +01:00

1693 lines
74 KiB
PowerShell

param(
[string]$RootPath = '',
[switch]$UseWinRAR = $false
)
$ErrorActionPreference = 'Continue'
# ---------------------------------------------------------------------------
# Download with progress bar
# ---------------------------------------------------------------------------
# Uses System.Net.WebClient for chunk-based downloading with a real-time
# console progress bar showing percentage, speed, and ETA. Falls back to
# chunked HttpWebRequest if the WebClient approach fails for any reason.
# ---------------------------------------------------------------------------
function Download-FileWithProgress {
param(
[Parameter(Mandatory=$true)]
[string]$Url,
[Parameter(Mandatory=$true)]
[string]$OutFile
)
# Ensure TLS 1.2 for modern HTTPS endpoints
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# Pre-define format strings as variables to avoid Windows PowerShell 5.1
# parser errors caused by curly braces inside inline -f expressions.
$fmtSizeMB = ' Downloading {0:N1} MB...'
$fmtBar = ' [{0}] {1,3}% {2:N1}/{3:N1} MB {4:N1} MB/s ETA {5}'
$fmtCarriage = "`r{0}"
$fmtEtaMin = '{0}m {1}s'
$fmtEtaSec = '{0}s'
$fmtUnknownDl = "`r Downloaded {0:N1} MB {1:N1} MB/s "
$fmtUnknownFin = ' Download complete ({0:N1} MB).'
# Try the chunked WebClient approach with progress bar
$useWebClient = $true
try {
$webClient = New-Object System.Net.WebClient
# First, get the file size via a HEAD request so we can show percentage
$totalBytes = 0L
try {
$req = [System.Net.HttpWebRequest]::Create($Url)
$req.Method = 'HEAD'
$req.Timeout = 15000
$req.AllowAutoRedirect = $true
$resp = $req.GetResponse()
$totalBytes = $resp.ContentLength
$resp.Close()
} catch {
# HEAD not supported or failed -- we'll still download, just without total size
$totalBytes = 0L
}
if ($totalBytes -gt 0) {
# --- We know the total size: show a percentage progress bar ---
$sizeMB = $totalBytes / 1MB
Write-Host ($fmtSizeMB -f $sizeMB)
$chunkSize = 256KB
$downloaded = 0L
$sw = [System.Diagnostics.Stopwatch]::StartNew()
# Open the remote stream
$remoteStream = $webClient.OpenRead($Url)
# Create the local file stream
$fileStream = [System.IO.File]::Create($OutFile)
$buffer = New-Object byte[] $chunkSize
$lastPct = -1
while ($true) {
$read = $remoteStream.Read($buffer, 0, $buffer.Length)
if ($read -le 0) { break }
$fileStream.Write($buffer, 0, $read)
$downloaded += $read
$pct = [int](($downloaded * 100) / $totalBytes)
if ($pct -ne $lastPct) {
$lastPct = $pct
# Build the visual progress bar using string multiplication
# NOTE: PowerShell 5.1 does NOT support [char] * [int], so
# we use string constructor instead (String([char], count)).
$barWidth = 30
$filled = [int]($barWidth * $pct / 100)
$empty = $barWidth - $filled
$blockChar = [string][char]0x2588 # full block
$lightChar = [string][char]0x2591 # light shade
$barStr = ($blockChar * $filled) + ($lightChar * $empty)
# Calculate speed and ETA
$elapsed = $sw.Elapsed.TotalSeconds
$speedMB = 0
if ($elapsed -gt 0) { $speedMB = ($downloaded / 1MB) / $elapsed }
$etaSec = 0
if ($speedMB -gt 0) { $etaSec = (($totalBytes - $downloaded) / 1MB) / $speedMB }
# Format ETA
$etaStr = ''
if ($etaSec -ge 60) {
$etaMin = [int]($etaSec / 60)
$etaRem = [int]($etaSec % 60)
$etaStr = $fmtEtaMin -f $etaMin, $etaRem
} else {
$etaStr = $fmtEtaSec -f [int]$etaSec
}
# Downloaded so far in MB
$dlMB = $downloaded / 1MB
$totalMB = $totalBytes / 1MB
# Write the progress line (overwrites itself using carriage return)
$progressLine = $fmtBar -f $barStr, $pct, $dlMB, $totalMB, $speedMB, $etaStr
Write-Host -NoNewline ($fmtCarriage -f $progressLine)
}
}
Write-Host '' # newline after the progress bar
$fileStream.Close()
$remoteStream.Close()
$sw.Stop()
# Verify file was written
if ((Test-Path -LiteralPath $OutFile -PathType Leaf) -and ((Get-Item -LiteralPath $OutFile).Length -gt 0)) {
Write-Host ' Download complete.'
return $true
} else {
return $false
}
} else {
# --- Unknown total size: show bytes downloaded with speed ---
Write-Host ' Downloading (unknown size)...'
$chunkSize = 256KB
$downloaded = 0L
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$remoteStream = $webClient.OpenRead($Url)
$fileStream = [System.IO.File]::Create($OutFile)
$buffer = New-Object byte[] $chunkSize
$lastPrint = [DateTime]::MinValue
while ($true) {
$read = $remoteStream.Read($buffer, 0, $buffer.Length)
if ($read -le 0) { break }
$fileStream.Write($buffer, 0, $read)
$downloaded += $read
# Update progress every 0.5 seconds
$now = [DateTime]::UtcNow
if (($now - $lastPrint).TotalSeconds -ge 0.5) {
$lastPrint = $now
$elapsed = $sw.Elapsed.TotalSeconds
$speedMB = 0
if ($elapsed -gt 0) { $speedMB = ($downloaded / 1MB) / $elapsed }
$dlMB = $downloaded / 1MB
Write-Host -NoNewline ($fmtUnknownDl -f $dlMB, $speedMB)
}
}
Write-Host '' # newline after the progress line
$fileStream.Close()
$remoteStream.Close()
$sw.Stop()
if ((Test-Path -LiteralPath $OutFile -PathType Leaf) -and ((Get-Item -LiteralPath $OutFile).Length -gt 0)) {
$finalMB = [Math]::Round($downloaded / 1MB, 1)
Write-Host ($fmtUnknownFin -f $finalMB)
return $true
} else {
return $false
}
}
} catch {
# WebClient approach failed -- fall back to chunked HttpWebRequest download
Write-Host ' WebClient download failed, falling back to chunked HTTP download...'
Write-Host " Error: $($_.Exception.Message)"
$useWebClient = $false
}
# Fallback: use manual chunked HTTP download with byte/MB progress display
if (-not $useWebClient) {
try {
Write-Host ' Downloading (fallback mode - showing bytes downloaded)...'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# Use HttpWebRequest directly so we get the response stream and can
# read chunks while showing progress (Invoke-WebRequest has no chunk API)
$req = [System.Net.HttpWebRequest]::Create($Url)
$req.Method = 'GET'
$req.Timeout = 120000
$req.AllowAutoRedirect = $true
$resp = $req.GetResponse()
$totalBytes = $resp.ContentLength
$respStream = $resp.GetResponseStream()
$fileStream = [System.IO.File]::Create($OutFile)
$buffer = New-Object byte[] 65536
$totalDownloaded = 0L
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$lastPrint = [DateTime]::MinValue
if ($totalBytes -gt 0) {
# Known size - show percentage-based progress
$sizeMB = $totalBytes / 1MB
Write-Host " File size: $([Math]::Round($sizeMB, 1)) MB"
$blockChar = [string][char]0x2588
$lightChar = [string][char]0x2591
$lastPct = -1
while ($true) {
$read = $respStream.Read($buffer, 0, $buffer.Length)
if ($read -le 0) { break }
$fileStream.Write($buffer, 0, $read)
$totalDownloaded += $read
$pct = [int](($totalDownloaded * 100) / $totalBytes)
if ($pct -ne $lastPct) {
$lastPct = $pct
$barWidth = 30
$filled = [int]($barWidth * $pct / 100)
$empty = $barWidth - $filled
$barStr = ($blockChar * $filled) + ($lightChar * $empty)
$elapsed = $sw.Elapsed.TotalSeconds
$speedMB = 0
if ($elapsed -gt 0) { $speedMB = ($totalDownloaded / 1MB) / $elapsed }
$etaSec = 0
if ($speedMB -gt 0) { $etaSec = (($totalBytes - $totalDownloaded) / 1MB) / $speedMB }
$etaStr = ''
if ($etaSec -ge 60) {
$etaStr = '{0}m {1}s' -f [int]($etaSec / 60), [int]($etaSec % 60)
} else {
$etaStr = '{0}s' -f [int]$etaSec
}
$dlMB = $totalDownloaded / 1MB
$totalMB = $totalBytes / 1MB
$progressLine = ' [{0}] {1,3}% {2:N1}/{3:N1} MB {4:N1} MB/s ETA {5}' -f $barStr, $pct, $dlMB, $totalMB, $speedMB, $etaStr
Write-Host -NoNewline ("`r{0}" -f $progressLine)
}
}
} else {
# Unknown size - show MB/bytes downloaded with speed
while ($true) {
$read = $respStream.Read($buffer, 0, $buffer.Length)
if ($read -le 0) { break }
$fileStream.Write($buffer, 0, $read)
$totalDownloaded += $read
$now = [DateTime]::UtcNow
if (($now - $lastPrint).TotalSeconds -ge 0.5) {
$lastPrint = $now
$elapsed = $sw.Elapsed.TotalSeconds
$speedMB = 0
if ($elapsed -gt 0) { $speedMB = ($totalDownloaded / 1MB) / $elapsed }
$dlMB = $totalDownloaded / 1MB
Write-Host -NoNewline ("`r Downloaded {0:N1} MB {1:N1} MB/s " -f $dlMB, $speedMB)
}
}
}
Write-Host '' # newline after the progress line
$fileStream.Close()
$respStream.Close()
$resp.Close()
$sw.Stop()
if ((Test-Path -LiteralPath $OutFile -PathType Leaf) -and ((Get-Item -LiteralPath $OutFile).Length -gt 0)) {
$finalMB = [Math]::Round($totalDownloaded / 1MB, 1)
Write-Host " Download complete ($finalMB MB)."
return $true
}
} catch {
Write-Host " Download failed: $($_.Exception.Message)"
}
}
return $false
}
# -- Resolve the addon root directory ----------------------------------------
# $PSScriptRoot can be empty when invoked via powershell -File from a .bat
if ($RootPath -eq '') {
$RootPath = $PSScriptRoot
}
if ($RootPath -eq '') {
$RootPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
}
if ($RootPath -eq '') {
# Last resort: use the current working directory
$RootPath = (Get-Location -PSProvider FileSystem).ProviderPath
}
# Strip any trailing slashes or quotes that cause "Illegal characters in path"
$RootPath = $RootPath.TrimEnd('\', '/').Trim('"', "'")
# Normalize to a full absolute path - use GetFullPath on the cleaned string
$Root = [System.IO.Path]::GetFullPath($RootPath)
$Media = Join-Path $Root 'Media'
$Out = Join-Path $Root 'AutoPlaylist.lua'
# WoW reads paths with backslashes; inside Lua strings a single backslash
# must be written as \\ (two characters in the source file). Escape-LuaString
# will double every \ it sees, so we store the prefix with SINGLE backslashes
# here -- after escaping they become \\ in the Lua source, which Lua reads as \.
$AddonPrefix = 'Interface\AddOns\RelationshipsJukeBox\'
# ---------------------------------------------------------------------------
# Auto-download ffmpeg into the RelationshipsJukeBox folder if not found
# ---------------------------------------------------------------------------
# We look for ffmpeg/ffprobe on PATH first, then in a local "ffmpeg" subfolder
# inside the addon directory. If neither exists, we download a static build
# from gyan.dev (the official ffmpeg.org-linked Windows builds) or from
# BtbN/FFmpeg-Builds on GitHub, extract it, and place ffmpeg.exe + ffprobe.exe
# into a "ffmpeg" folder next to this script.
#
# IMPORTANT: gyan.dev distributes .7z archives (smaller). We need 7-Zip or
# WinRAR to extract them. If neither is available, we fall back to .zip
# archives from gyan.dev or GitHub (BtbN) which PowerShell can extract natively.
# ---------------------------------------------------------------------------
$FfmpegDir = Join-Path $Root 'ffmpeg'
function Find-Ffmpeg {
param([string]$Name)
# 1) On PATH already?
$found = Get-Command $Name -ErrorAction SilentlyContinue
if ($found) { return $found.Source }
# 2) In our local ffmpeg subfolder?
$local = Join-Path $FfmpegDir "$Name.exe"
if (Test-Path -LiteralPath $local -PathType Leaf) { return $local }
return $null
}
function Find-SevenZip {
# 1) On PATH?
$found = Get-Command '7z' -ErrorAction SilentlyContinue
if ($found) { return $found.Source }
# 2) Default install location (7-Zip installs here on 64-bit)
$default64 = Join-Path $env:ProgramFiles '7-Zip\7z.exe'
if (Test-Path -LiteralPath $default64 -PathType Leaf) { return $default64 }
# 3) 32-bit on 64-bit system
$default32 = Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe'
if (Test-Path -LiteralPath $default32 -PathType Leaf) { return $default32 }
return $null
}
function Find-WinRAR {
# 1) On PATH?
$found = Get-Command 'winrar' -ErrorAction SilentlyContinue
if ($found) { return $found.Source }
# 2) On PATH as rar.exe (command-line variant)?
$foundRar = Get-Command 'rar' -ErrorAction SilentlyContinue
if ($foundRar) {
# rar.exe is typically in the WinRAR install folder alongside WinRAR.exe
$rarDir = Split-Path -Parent $foundRar.Source
$winrarExe = Join-Path $rarDir 'WinRAR.exe'
if (Test-Path -LiteralPath $winrarExe -PathType Leaf) { return $winrarExe }
# unrar.exe can also extract
return $foundRar.Source
}
# 3) On PATH as unrar.exe (extraction-only variant)?
$foundUnrar = Get-Command 'unrar' -ErrorAction SilentlyContinue
if ($foundUnrar) { return $foundUnrar.Source }
# 4) Default 64-bit install location
$default64 = Join-Path $env:ProgramFiles 'WinRAR\WinRAR.exe'
if (Test-Path -LiteralPath $default64 -PathType Leaf) { return $default64 }
# 5) Default 32-bit install on 64-bit system
$default32 = Join-Path ${env:ProgramFiles(x86)} 'WinRAR\WinRAR.exe'
if (Test-Path -LiteralPath $default32 -PathType Leaf) { return $default32 }
# 6) Try common non-ProgramFiles locations
$commonPaths = @(
'C:\WinRAR\WinRAR.exe',
'D:\WinRAR\WinRAR.exe',
(Join-Path $env:ProgramFiles 'RAR\WinRAR.exe'),
(Join-Path ${env:ProgramFiles(x86)} 'RAR\WinRAR.exe')
)
foreach ($p in $commonPaths) {
if (Test-Path -LiteralPath $p -PathType Leaf) { return $p }
}
# 7) Try to find unrar.exe in WinRAR folder as fallback
$unrar64 = Join-Path $env:ProgramFiles 'WinRAR\UnRAR.exe'
if (Test-Path -LiteralPath $unrar64 -PathType Leaf) { return $unrar64 }
$unrar32 = Join-Path ${env:ProgramFiles(x86)} 'WinRAR\UnRAR.exe'
if (Test-Path -LiteralPath $unrar32 -PathType Leaf) { return $unrar32 }
return $null
}
# -UseWinRAR flag is set via the script parameter above
function Ensure-FfmpegInstalled {
$ffmpegExe = Find-Ffmpeg -Name 'ffmpeg'
$ffprobeExe = Find-Ffmpeg -Name 'ffprobe'
if ($ffmpegExe -and $ffprobeExe) {
Write-Host "ffmpeg found: $ffmpegExe"
Write-Host "ffprobe found: $ffprobeExe"
return
}
Write-Host ""
Write-Host "============================================"
Write-Host " ffmpeg not found -- auto-downloading..."
Write-Host "============================================"
Write-Host ""
# Check if we have 7-Zip and/or WinRAR available for .7z extraction
$sevenZipExe = Find-SevenZip
$winRarExe = Find-WinRAR
# Determine extraction preference order based on -UseWinRAR flag
# When -UseWinRAR is specified, WinRAR is preferred over 7-Zip for all formats
$preferWinRAR = $UseWinRAR -and $winRarExe
$downloadDir = Join-Path $Root 'ffmpeg_download'
if (-not (Test-Path -LiteralPath $downloadDir -PathType Container)) {
New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null
}
$downloaded = $false
$downloadedFile = $null
# We can extract .7z if we have 7-Zip OR WinRAR
$canExtract7z = ($sevenZipExe -ne $null) -or ($winRarExe -ne $null)
if ($canExtract7z) {
# We have an extraction tool -- download the smaller .7z release
if ($preferWinRAR) {
Write-Host "WinRAR found (preferred): $winRarExe"
} elseif ($sevenZipExe) {
Write-Host "7-Zip found: $sevenZipExe"
}
if ($winRarExe -and -not $preferWinRAR) {
Write-Host "WinRAR also found: $winRarExe"
}
Write-Host "Downloading ffmpeg .7z release (smaller archive)..."
# gyan.dev URL scheme (as of 2026-07):
# - Short alias URLs that 303-redirect to dated packages under /packages/
# - ffmpeg-release-essentials.7z -> /packages/ffmpeg-X.Y.Z-essentials_build.7z
# - ffmpeg-git-full.7z -> /packages/ffmpeg-YYYY-MM-DD-git-HASH-full_build.7z
# - ffmpeg-release-full.7z -> /packages/ffmpeg-X.Y.Z-full_build.7z
# - ffmpeg-git-essentials.7z -> /packages/ffmpeg-YYYY-MM-DD-git-HASH-essentials_build.7z
# NOTE: ffmpeg-release-full.7z exists but there is no .zip for the full build.
# There is also NO /packages/ffmpeg-release-full.7z direct URL (404).
# Use the short alias URLs -- they redirect (303) to the correct dated file.
$urls7z = @(
# Release essentials (smaller, ~32 MB) -- has everything we need
"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.7z",
# Git master essentials (latest dev build)
"https://www.gyan.dev/ffmpeg/builds/ffmpeg-git-essentials.7z",
# Release full build (more codecs, ~70 MB)
"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z",
# Git master full build (latest dev build, full)
"https://www.gyan.dev/ffmpeg/builds/ffmpeg-git-full.7z"
)
foreach ($tryUrl in $urls7z) {
$archName = [System.IO.Path]::GetFileName($tryUrl)
$savePath = Join-Path $downloadDir $archName
Write-Host "Attempting: $tryUrl"
try {
$dlOk = Download-FileWithProgress -Url $tryUrl -OutFile $savePath
if ($dlOk) {
$downloaded = $true
$downloadedFile = $savePath
Write-Host "Download complete ($archName)."
break
}
} catch {
Write-Host "Failed: $($_.Exception.Message)"
if (Test-Path -LiteralPath $savePath) {
Remove-Item -LiteralPath $savePath -Force -ErrorAction SilentlyContinue
}
}
}
}
if (-not $downloaded) {
# .7z download failed or no extraction tool -- try .zip archives
# PowerShell can extract .zip natively with Expand-Archive
Write-Host ""
if (-not $canExtract7z) {
Write-Host "Neither 7-Zip nor WinRAR found -- cannot extract .7z archives."
Write-Host "Downloading .zip release instead (larger but no extra tools needed)..."
} else {
Write-Host ".7z downloads failed. Trying .zip archives..."
}
# gyan.dev .zip files:
# - ffmpeg-release-essentials.zip (~104 MB, PowerShell-native extraction)
# - ffmpeg-8.1.2-essentials_build.zip (versioned, under /packages/)
# NOTE: There is NO ffmpeg-release-full.zip on gyan.dev (404).
# So we also try BtbN/FFmpeg-Builds on GitHub which provides .zip archives.
$urlsZip = @(
# gyan.dev release essentials .zip (native PS extraction, ~104 MB)
"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip",
# BtbN GitHub - GPL shared build (has ffmpeg.exe + ffprobe.exe + DLLs, ~80 MB)
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl-shared.zip",
# BtbN GitHub - GPL static build (single ffmpeg.exe, no DLLs, ~100 MB)
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"
)
foreach ($tryUrl in $urlsZip) {
$archName = [System.IO.Path]::GetFileName($tryUrl)
$savePath = Join-Path $downloadDir $archName
Write-Host "Attempting: $tryUrl"
try {
$dlOk = Download-FileWithProgress -Url $tryUrl -OutFile $savePath
if ($dlOk) {
$downloaded = $true
$downloadedFile = $savePath
Write-Host "Download complete ($archName)."
break
}
} catch {
Write-Host "Failed: $($_.Exception.Message)"
if (Test-Path -LiteralPath $savePath) {
Remove-Item -LiteralPath $savePath -Force -ErrorAction SilentlyContinue
}
}
}
}
if (-not $downloaded) {
Write-Host ""
Write-Host "============================================================"
Write-Host " AUTOMATIC DOWNLOAD FAILED"
Write-Host "============================================================"
Write-Host ""
Write-Host "Please install ffmpeg manually using ONE of these methods:"
Write-Host ""
Write-Host " Option 1 - Download and extract into the addon folder:"
Write-Host " 1. Go to https://www.gyan.dev/ffmpeg/builds/"
Write-Host " 2. Download 'ffmpeg-release-essentials.zip' (or .7z if you have 7-Zip)"
Write-Host " 3. Extract the archive"
Write-Host " 4. Find ffmpeg.exe and ffprobe.exe inside the bin/ folder"
Write-Host " 5. Create a folder called 'ffmpeg' next to this script"
Write-Host " 6. Copy ffmpeg.exe and ffprobe.exe into that 'ffmpeg' folder"
Write-Host ""
Write-Host " Option 2 - Download from GitHub BtbN/FFmpeg-Builds:"
Write-Host " 1. Go to https://github.com/BtbN/FFmpeg-Builds/releases"
Write-Host " 2. Download 'ffmpeg-master-latest-win64-gpl-shared.zip'"
Write-Host " 3. Extract the archive"
Write-Host " 4. Find ffmpeg.exe and ffprobe.exe inside the bin/ folder"
Write-Host " 5. Create a folder called 'ffmpeg' next to this script"
Write-Host " 6. Copy ffmpeg.exe and ffprobe.exe into that 'ffmpeg' folder"
Write-Host ""
Write-Host " Option 3 - Install via Chocolatey (run as Admin):"
Write-Host " choco install ffmpeg"
Write-Host ""
Write-Host " Option 4 - Install via Winget:"
Write-Host " winget install ffmpeg"
Write-Host ""
Write-Host " Option 5 - Install via Scoop:"
Write-Host " scoop install ffmpeg-essentials"
Write-Host ""
Write-Host " Option 6 - Add ffmpeg to your system PATH:"
Write-Host " Download from https://ffmpeg.org/download.html"
Write-Host " Extract and add the bin folder to your PATH environment variable"
Write-Host ""
Write-Host "NOTE: gyan.dev distributes ffmpeg as .7z files (smaller)."
Write-Host "Install 7-Zip from https://www.7-zip.org/ for .7z support."
Write-Host "WinRAR from https://www.win-rar.com/ can also extract .7z files."
Write-Host "You can also run this script with -UseWinRAR to prefer WinRAR over 7-Zip."
Write-Host "A .zip release is also available from gyan.dev or GitHub but is larger."
Write-Host ""
Write-Host "The script will continue WITHOUT ffmpeg -- MP3 sanitization"
Write-Host "and accurate duration detection will be skipped."
Write-Host "============================================================"
Write-Host ""
return
}
# Extract the archive
Write-Host "Extracting ffmpeg..."
try {
# Create ffmpeg output directory
if (-not (Test-Path -LiteralPath $FfmpegDir -PathType Container)) {
New-Item -ItemType Directory -Path $FfmpegDir -Force | Out-Null
}
$extractBase = Join-Path $downloadDir 'extracted'
if (Test-Path -LiteralPath $extractBase) {
Remove-Item -LiteralPath $extractBase -Recurse -Force
}
$is7z = $downloadedFile.EndsWith('.7z')
if ($is7z) {
# Extract .7z -- prefer WinRAR if -UseWinRAR was specified, then 7-Zip, then WinRAR as fallback
if ($preferWinRAR -and $winRarExe) {
Write-Host "Using WinRAR to extract .7z archive..."
& $winRarExe x -y $downloadedFile "$extractBase\" 2>$null | Out-Null
} elseif ($sevenZipExe) {
Write-Host "Using 7-Zip to extract .7z archive..."
& $sevenZipExe x $downloadedFile -o"$extractBase" -y 2>$null | Out-Null
} elseif ($winRarExe) {
Write-Host "Using WinRAR to extract .7z archive..."
& $winRarExe x -y $downloadedFile "$extractBase\" 2>$null | Out-Null
} else {
Write-Host "ERROR: No tool available to extract .7z archive (need 7-Zip or WinRAR)."
throw "Cannot extract .7z file -- install 7-Zip or WinRAR, or re-run without .7z download"
}
} else {
# Extract .zip -- prefer WinRAR if -UseWinRAR, then 7-Zip, then PowerShell native
if ($preferWinRAR -and $winRarExe) {
Write-Host "Using WinRAR to extract .zip archive..."
& $winRarExe x -y $downloadedFile "$extractBase\" 2>$null | Out-Null
} elseif ($sevenZipExe) {
Write-Host "Using 7-Zip to extract .zip archive..."
& $sevenZipExe x $downloadedFile -o"$extractBase" -y 2>$null | Out-Null
} else {
Write-Host "Using PowerShell to extract .zip archive..."
Expand-Archive -LiteralPath $downloadedFile -DestinationPath $extractBase -Force
}
}
# Find the bin folder inside the extracted directory
# gyan.dev archives contain a folder like ffmpeg-8.1.2-essentials_build/ or
# ffmpeg-2026-06-29-git-de6bcf5c05-full_build/ with a bin/ subfolder inside.
# BtbN GitHub archives contain a folder like ffmpeg-master-latest-win64-gpl-shared/
# with a bin/ subfolder inside.
$binDir = Get-ChildItem -LiteralPath $extractBase -Recurse -Directory -Filter 'bin' |
Select-Object -First 1
if ($binDir) {
# Copy ffmpeg.exe and ffprobe.exe to our local ffmpeg folder
foreach ($exe in @('ffmpeg.exe', 'ffprobe.exe')) {
$src = Join-Path $binDir.FullName $exe
$dst = Join-Path $FfmpegDir $exe
if (Test-Path -LiteralPath $src -PathType Leaf) {
Copy-Item -LiteralPath $src -Destination $dst -Force
Write-Host "Installed: $exe -> $dst"
}
}
} else {
# Try to find ffmpeg.exe directly in extracted folder
$ffmpegFiles = Get-ChildItem -LiteralPath $extractBase -Recurse -File -Filter 'ffmpeg.exe'
$ffprobeFiles = Get-ChildItem -LiteralPath $extractBase -Recurse -File -Filter 'ffprobe.exe'
foreach ($f in $ffmpegFiles) {
Copy-Item -LiteralPath $f.FullName -Destination (Join-Path $FfmpegDir 'ffmpeg.exe') -Force
Write-Host "Installed: ffmpeg.exe"
}
foreach ($f in $ffprobeFiles) {
Copy-Item -LiteralPath $f.FullName -Destination (Join-Path $FfmpegDir 'ffprobe.exe') -Force
Write-Host "Installed: ffprobe.exe"
}
}
# Clean up download artifacts
Remove-Item -LiteralPath $extractBase -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $downloadedFile -Force -ErrorAction SilentlyContinue
# Remove download dir if empty
$remaining = Get-ChildItem -LiteralPath $downloadDir -ErrorAction SilentlyContinue
if (-not $remaining) {
Remove-Item -LiteralPath $downloadDir -Force -ErrorAction SilentlyContinue
}
# Verify installation
$ffTest = Join-Path $FfmpegDir 'ffmpeg.exe'
$fpTest = Join-Path $FfmpegDir 'ffprobe.exe'
if ((Test-Path -LiteralPath $ffTest) -and (Test-Path -LiteralPath $fpTest)) {
Write-Host ""
Write-Host "ffmpeg installed successfully in: $FfmpegDir"
Write-Host ""
} else {
Write-Host ""
Write-Host "WARNING: ffmpeg/ffprobe not found after extraction."
Write-Host "Please install manually (see instructions below)."
Write-Host ""
}
} catch {
Write-Host "ERROR: Failed to extract ffmpeg: $($_.Exception.Message)"
Write-Host "Please install ffmpeg manually (see instructions in the script output)."
}
}
# Run the auto-installer
Ensure-FfmpegInstalled
# ---------------------------------------------------------------------------
# Now that ffmpeg may be available, set up a helper to call it from the
# local ffmpeg folder when the system PATH doesn't have it.
# ---------------------------------------------------------------------------
# Override ffprobe/ffmpeg calls to use the local copy if needed
$localFfmpeg = Join-Path $FfmpegDir 'ffmpeg.exe'
$localFfprobe = Join-Path $FfmpegDir 'ffprobe.exe'
# We can't easily override the & operator, but we can define helper functions
function Invoke-LocalFfprobe {
param([string[]]$Arguments)
$exe = Find-Ffmpeg -Name 'ffprobe'
if ($exe) {
return & $exe @Arguments
}
return $null
}
function Invoke-LocalFfmpeg {
param([string[]]$Arguments)
$exe = Find-Ffmpeg -Name 'ffmpeg'
if ($exe) {
return & $exe @Arguments
}
return $null
}
if (-not (Test-Path -LiteralPath $Media -PathType Container)) {
throw "Media folder not found: $Media"
}
# -- Lua string escaper ------------------------------------------------------
function Escape-LuaString {
param([AllowNull()][string]$Value)
if ($null -eq $Value) { return '' }
$escaped = $Value.Replace('\', '\\')
$escaped = $escaped.Replace('"', '\"')
$escaped = $escaped.Replace("`n", '\n')
$escaped = $escaped.Replace("`r", '\r')
return $escaped
}
# -- Duration helpers (optional -- graceful fallback) -------------------------
$shell = $null
$wmp = $null
try { $shell = New-Object -ComObject Shell.Application } catch { $shell = $null }
try { $wmp = New-Object -ComObject WMPlayer.OCX.7 } catch { $wmp = $null }
# Default duration assigned when we cannot read metadata (seconds).
# This lets autoplay/shuffle work without metadata -- the Lua side uses
# this as the wait time before advancing to the next track.
$DEFAULT_DURATION = 180 # 3 minutes -- reasonable for most songs
function Get-TrackDurationSeconds {
param([System.IO.FileInfo]$File)
# --- Try Shell.Application (works for mp3 with Windows property handlers) ---
if ($shell) {
try {
$folder = $shell.Namespace($File.DirectoryName)
if ($folder) {
$item = $folder.ParseName($File.Name)
if ($item) {
# 27 = Duration column in Shell folder view (HH:MM:SS string)
$raw = $item.ExtendedProperty('System.Media.Duration')
$ticks = 0L
if ($raw -is [ValueType]) {
$ticks = [int64]$raw
} elseif ($raw) {
[void][int64]::TryParse([string]$raw, [ref]$ticks)
}
if ($ticks -gt 0) {
return [int][Math]::Ceiling($ticks / 10000000.0)
}
# Fallback: try the Duration column (index 27) as a string
$durStr = $folder.GetDetailsOf($item, 27)
if ($durStr -and $durStr.Trim() -ne '') {
# Parse HH:MM:SS or MM:SS
$parts = $durStr.Trim() -split ':'
$secs = 0
if ($parts.Count -eq 3) {
$secs = [int]$parts[0]*3600 + [int]$parts[1]*60 + [int]$parts[2]
} elseif ($parts.Count -eq 2) {
$secs = [int]$parts[0]*60 + [int]$parts[1]
}
if ($secs -gt 0) { return $secs }
}
}
}
} catch {
# Silently skip -- special characters in filename can break COM
}
}
# --- Try Windows Media Player COM ---
if ($wmp) {
try {
$mediaItem = $wmp.newMedia($File.FullName)
if ($mediaItem) {
$duration = [double]$mediaItem.duration
if ($duration -gt 0) {
return [int][Math]::Ceiling($duration)
}
}
} catch {
# Silently skip -- special characters in filename can break COM
}
}
# --- Try ffprobe if available ---
try {
$ffprobeExe = Find-Ffmpeg -Name 'ffprobe'
if ($ffprobeExe) {
# Use Start-Process to avoid crashes from ffprobe stderr/non-zero exit
$ffOutFile = [System.IO.Path]::GetTempFileName()
$ffErrFile = [System.IO.Path]::GetTempFileName()
$ffProc = Start-Process -FilePath $ffprobeExe -ArgumentList @('-v', 'quiet', '-print_format', 'json', '-show_format', $File.FullName) -NoNewWindow -Wait -PassThru -RedirectStandardOutput $ffOutFile -RedirectStandardError $ffErrFile
Remove-Item -LiteralPath $ffErrFile -Force -ErrorAction SilentlyContinue
if ($ffProc.ExitCode -eq 0 -and (Test-Path -LiteralPath $ffOutFile -PathType Leaf)) {
$ffOut = Get-Content -LiteralPath $ffOutFile -Raw
Remove-Item -LiteralPath $ffOutFile -Force -ErrorAction SilentlyContinue
if ($ffOut) {
$ffJson = $ffOut | ConvertFrom-Json
if ($ffJson.format.duration) {
$dur = [double]$ffJson.format.duration
if ($dur -gt 0) { return [int][Math]::Ceiling($dur) }
}
}
} else {
Remove-Item -LiteralPath $ffOutFile -Force -ErrorAction SilentlyContinue
}
}
} catch {
# ffprobe not installed or failed, skip
}
# --- Fallback: estimate from file size (very rough) ---
# For mp3 @ ~128kbps: ~1KB per second. For WAV: ~172KB per second (16bit/44.1k/stereo)
if ($File.Extension -match '^(?i)\.mp3$') {
$estimate = [int][Math]::Ceiling($File.Length / 16000.0) # ~128kbps
if ($estimate -gt 10) { return $estimate }
} elseif ($File.Extension -match '^(?i)\.wav$') {
$estimate = [int][Math]::Ceiling($File.Length / 176000.0) # ~16bit/44.1k/stereo
if ($estimate -gt 10) { return $estimate }
} elseif ($File.Extension -match '^(?i)\.ogg$') {
$estimate = [int][Math]::Ceiling($File.Length / 12000.0) # ~112kbps vorbis
if ($estimate -gt 10) { return $estimate }
}
# --- Ultimate fallback: use default duration ---
return $DEFAULT_DURATION
}
# -- Sanitize MP3 files for WoW 1.12 compatibility ---------------------------
# WoW 1.12's FMOD 3.x engine is very picky about MP3 files. Many tracks that
# play fine in modern players will silently fail in-game. Known issues include:
#
# 1. CRC protection bit set (protection_bit = 0 in MPEG frame header)
# 2. Embedded album art / ID3v2 APIC frames (FMOD may misparse these as video)
# 3. Variable bitrate (VBR) encoding with Xing/Info headers
# 4. Non-standard MPEG version/layer combinations (MPEG 2.0/2.5, Layer I/II)
# 5. Unusual channel modes (dual channel, joint stereo variants)
# 6. Very high bitrates (>256kbps) that FMOD 3.x sometimes chokes on
# 7. LAME-encoded files with non-standard delay/padding info tags
# 8. ID3v2 extended headers or unsynchronization that confuse FMOD
# 9. Files with large ID3v2 tags where FMOD fails to find the audio start
# 10. Files encoded with encoders that produce MPEG frame padding inconsistencies
#
# The ONLY reliable fix is to re-encode with ffmpeg using settings known to
# produce FMOD 3.x-compatible output. This script checks each MP3 for any
# of these issues and re-encodes only those that need it. If ffmpeg is not
# available, it tries a binary patch to at least fix CRC protection.
#
# Re-encoded files use: CBR 256kbps, MPEG1 Layer3, Simple Stereo, no CRC,
# no embedded art, stripped tags, standard LAME encoding.
function Test-Mp3NeedsReencode {
param([string]$FilePath)
try {
$bytes = [System.IO.File]::ReadAllBytes($FilePath)
if ($bytes.Length -lt 10) { return $false, 'File too small' }
# --- Check for ID3v2 tag and find audio start ---
$audioOffset = 0
$hasId3v2 = $false
$hasExtendedHeader = $false
$hasUnsynchronization = $false
$id3v2Size = 0
if ($bytes[0] -eq 0x49 -and $bytes[1] -eq 0x44 -and $bytes[2] -eq 0x33) {
# ID3v2 tag present
$hasId3v2 = $true
$id3v2Flags = $bytes[5]
$hasUnsynchronization = ($id3v2Flags -band 0x80) -ne 0
$hasExtendedHeader = ($id3v2Flags -band 0x40) -ne 0
$id3v2Size = ($bytes[6] -shl 21) -bor ($bytes[7] -shl 14) -bor ($bytes[8] -shl 7) -bor $bytes[9]
$audioOffset = $id3v2Size + 10
# Check for embedded album art (APIC frame) in ID3v2
# Scan the ID3v2 tag for 'APIC' frame ID
$tagEnd = [Math]::Min($id3v2Size + 10, $bytes.Length)
for ($j = 10; $j -lt $tagEnd - 4; $j++) {
if ($bytes[$j] -eq 0x41 -and $bytes[$j+1] -eq 0x50 -and $bytes[$j+2] -eq 0x49 -and $bytes[$j+3] -eq 0x43) {
# APIC frame found = embedded album art
return $true, 'Embedded album art (APIC frame in ID3v2 tag)'
}
# Also check for 'PIC ' (ID3v2.2 picture frame)
if ($bytes[$j] -eq 0x50 -and $bytes[$j+1] -eq 0x49 -and $bytes[$j+2] -eq 0x43 -and $bytes[$j+3] -eq 0x20) {
return $true, 'Embedded album art (PIC frame in ID3v2 tag)'
}
}
}
if ($hasUnsynchronization) {
return $true, 'ID3v2 unsynchronization flag set (can confuse FMOD 3.x)'
}
if ($hasExtendedHeader) {
return $true, 'ID3v2 extended header present (can confuse FMOD 3.x)'
}
# --- Scan MPEG audio frames ---
if ($audioOffset -ge $bytes.Length - 4) { return $false, 'No audio data found' }
$frameCount = 0
$maxFramesToCheck = 50
$foundXing = $false
$foundInfo = $false
$hasCrc = $false
$hasVbr = $false
$hasHighBitrate = $false
$hasNonStandardMode = $false
$hasMpeg2 = $false
$prevBitrateIdx = -1
$bitrateChanges = 0
# Bitrate table for MPEG1 Layer III (index 0 = free, 15 = bad)
$bitrateTable1L3 = @(0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0)
for ($i = $audioOffset; $i -lt [Math]::Min($bytes.Length - 4, $audioOffset + 65536); $i++) {
# Look for MPEG sync word
if ($bytes[$i] -ne 0xFF -or ($bytes[$i+1] -band 0xE0) -ne 0xE0) {
continue
}
$b1 = $bytes[$i+1]
$b2 = $bytes[$i+2]
# Decode MPEG header
$mpegVersion = ($b1 -shr 3) -band 0x03 # 0=2.5, 2=2.0, 3=1.0
$layer = ($b1 -shr 1) -band 0x03 # 1=Layer3, 2=Layer2, 3=Layer1
$protection = $b1 -band 0x01 # 0=CRC present, 1=no CRC
$bitrateIdx = ($b2 -shr 4) -band 0x0F
$sampleRateIdx = ($b2 -shr 2) -band 0x03
$channelMode = ($bytes[$i+3] -shr 6) -band 0x03 # 0=Stereo, 1=Joint, 2=Dual, 3=Mono
# Check for MPEG 2.0 or 2.5 (WoW 1.12 FMOD may not handle these)
if ($mpegVersion -eq 0 -or $mpegVersion -eq 2) {
$hasMpeg2 = $true
}
# Check for non-Layer3 (WoW 1.12 FMOD may not handle Layer I/II)
if ($layer -ne 1) {
$hasNonStandardMode = $true
}
# Check CRC protection
if ($protection -eq 0) {
$hasCrc = $true
}
# Check bitrate
if ($mpegVersion -eq 3 -and $layer -eq 1) {
# MPEG1 Layer3
$br = $bitrateTable1L3[$bitrateIdx]
if ($br -ge 320) {
$hasHighBitrate = $true # 320kbps sometimes causes FMOD issues
}
# Track bitrate changes for VBR detection
if ($prevBitrateIdx -ge 0 -and $bitrateIdx -ne $prevBitrateIdx) {
$bitrateChanges++
}
$prevBitrateIdx = $bitrateIdx
}
# Check for Xing/Info header (VBR indicator) - usually at offset 36 from frame start
# for MPEG1 Layer3 stereo: after 32-byte side information
if ($i + 72 -lt $bytes.Length) {
$xingOffset = $i + 36
if ($bytes[$xingOffset] -eq 0x58 -and $bytes[$xingOffset+1] -eq 0x69 -and $bytes[$xingOffset+2] -eq 0x6E -and $bytes[$xingOffset+3] -eq 0x67) {
$foundXing = $true
$hasVbr = $true
}
if ($bytes[$xingOffset] -eq 0x49 -and $bytes[$xingOffset+1] -eq 0x6E -and $bytes[$xingOffset+2] -eq 0x66 -and $bytes[$xingOffset+3] -eq 0x6F) {
$foundInfo = $true
# Info header can indicate CBR but still has LAME tag that may cause issues
}
}
$frameCount++
if ($frameCount -ge $maxFramesToCheck) { break }
# Calculate frame size to skip to next frame
if ($mpegVersion -eq 3 -and $layer -eq 1) {
$br = $bitrateTable1L3[$bitrateIdx]
if ($br -gt 0) {
$padding = ($b2 -shr 1) -band 0x01
$frameSize = [int]([Math]::Floor(144.0 * $br * 1000 / 44100)) + $padding
$i += $frameSize - 1 # -1 because the for loop does $i++
} else {
$i += 417
}
} else {
# Non-standard frame, skip forward conservatively
$i += 417
}
}
# --- Return findings ---
# VBR detection: Xing header or multiple bitrate changes
if ($foundXing) {
return $true, 'VBR encoding (Xing header found - FMOD 3.x often fails on VBR)'
}
if ($bitrateChanges -ge 3) {
return $true, 'VBR encoding (bitrate changes detected across frames)'
}
# CRC protection
if ($hasCrc) {
return $true, 'CRC protection bit set (FMOD 3.x cannot play CRC-protected MP3)'
}
# Embedded art was already checked above, but also check via file size heuristic
# If there's an ID3v2 tag larger than 100KB, it likely has embedded art
if ($hasId3v2 -and $id3v2Size -gt 102400) {
return $true, "Large ID3v2 tag ($([int]($id3v2Size/1024))KB - likely contains embedded art)"
}
# High bitrate (320kbps) with CRC or other issues
if ($hasHighBitrate -and $hasCrc) {
return $true, '320kbps with CRC protection (known to fail in FMOD 3.x)'
}
# Non-standard MPEG version/layer
if ($hasMpeg2) {
return $true, 'MPEG 2.0/2.5 audio detected (FMOD 3.x may not support)'
}
if ($hasNonStandardMode) {
return $true, 'Non-Layer3 MPEG audio detected (FMOD 3.x may not support)'
}
# Dual channel mode (rare, sometimes causes FMOD issues)
if ($channelMode -eq 2 -and $frameCount -gt 0) {
return $true, 'Dual channel mode detected (FMOD 3.x may not handle correctly)'
}
# Check for video/image streams embedded in the MP3 file
# Some MP3 files have embedded JPEG/PNG album art as a separate "stream"
# FMOD may try to play these as video and fail
try {
$ffprobeExe = Find-Ffmpeg -Name 'ffprobe'
if ($ffprobeExe) {
$ffOutFile2 = [System.IO.Path]::GetTempFileName()
$ffErrFile2 = [System.IO.Path]::GetTempFileName()
$ffProc2 = Start-Process -FilePath $ffprobeExe -ArgumentList @('-v', 'quiet', '-print_format', 'json', '-show_streams', $FilePath) -NoNewWindow -Wait -PassThru -RedirectStandardOutput $ffOutFile2 -RedirectStandardError $ffErrFile2
Remove-Item -LiteralPath $ffErrFile2 -Force -ErrorAction SilentlyContinue
if ($ffProc2.ExitCode -eq 0 -and (Test-Path -LiteralPath $ffOutFile2 -PathType Leaf)) {
$ffProbeOut = Get-Content -LiteralPath $ffOutFile2 -Raw
Remove-Item -LiteralPath $ffOutFile2 -Force -ErrorAction SilentlyContinue
if ($ffProbeOut) {
$ffJson = $ffProbeOut | ConvertFrom-Json
foreach ($stream in $ffJson.streams) {
if ($stream.codec_type -eq 'video') {
return $true, "Embedded video/image stream (codec: $($stream.codec_name)) - FMOD 3.x cannot handle"
}
}
}
} else {
Remove-Item -LiteralPath $ffOutFile2 -Force -ErrorAction SilentlyContinue
}
}
} catch {
# ffprobe not available or failed, skip this check
}
return $false, 'Compatible'
}
catch {
return $true, "Error analyzing file: $($_.Exception.Message)"
}
}
function Sanitize-Mp3File {
param([System.IO.FileInfo]$File)
$tempFile = $File.FullName + '.sanitize.tmp'
# Re-encode with ffmpeg: strip video/art, no CRC, CBR 256kbps, simple stereo,
# no tags (stripped), no VBR, standard MPEG1 Layer3
# Using 256kbps instead of 320kbps because FMOD 3.x is more reliable at this bitrate
$ffmpegExe = Find-Ffmpeg -Name 'ffmpeg'
$result = $null
try {
if ($ffmpegExe) {
# Capture stderr to prevent it from interfering with the console
# and check the exit code manually rather than relying on ErrorActionPreference
$stderrFile = [System.IO.Path]::GetTempFileName()
$proc = Start-Process -FilePath $ffmpegExe -ArgumentList @('-y', '-i', $File.FullName, '-vn', '-c:a', 'libmp3lame', '-b:a', '256k', '-ac', '2', '-ar', '44100', '-joint_stereo', '0', '-map_metadata', '-1', $tempFile) -NoNewWindow -Wait -PassThru -RedirectStandardError $stderrFile
Remove-Item -LiteralPath $stderrFile -Force -ErrorAction SilentlyContinue
if ($proc.ExitCode -ne 0) {
Write-Host " ffmpeg exited with code $($proc.ExitCode) for $($File.Name), trying alternate settings..."
}
}
} catch {
# ffmpeg failed -- cannot sanitize, continue to next method
Write-Host " ffmpeg re-encode failed for $($File.Name): $($_.Exception.Message)"
}
if ((Test-Path -LiteralPath $tempFile -PathType Leaf) -and ((Get-Item -LiteralPath $tempFile).Length -gt 0)) {
# Verify the sanitized file passes all checks
$needsMore, $reason = Test-Mp3NeedsReencode -FilePath $tempFile
if (-not $needsMore) {
Move-Item -LiteralPath $tempFile -Destination $File.FullName -Force
Write-Host " Sanitized (re-encoded for WoW 1.12): $($File.Name)"
return $true
} else {
# Even after re-encode, still has issues - try one more time with different settings
Remove-Item -LiteralPath $tempFile -Force
$tempFile2 = $File.FullName + '.sanitize2.tmp'
try {
$ffmpegExe2 = Find-Ffmpeg -Name 'ffmpeg'
if ($ffmpegExe2) {
$stderrFile2 = [System.IO.Path]::GetTempFileName()
$proc2 = Start-Process -FilePath $ffmpegExe2 -ArgumentList @('-y', '-i', $File.FullName, '-vn', '-c:a', 'libmp3lame', '-b:a', '192k', '-ac', '2', '-ar', '44100', '-joint_stereo', '0', '-map_metadata', '-1', $tempFile2) -NoNewWindow -Wait -PassThru -RedirectStandardError $stderrFile2
Remove-Item -LiteralPath $stderrFile2 -Force -ErrorAction SilentlyContinue
}
if ((Test-Path -LiteralPath $tempFile2 -PathType Leaf) -and ((Get-Item -LiteralPath $tempFile2).Length -gt 0)) {
Move-Item -LiteralPath $tempFile2 -Destination $File.FullName -Force
Write-Host " Sanitized (re-encoded at 192kbps for WoW 1.12): $($File.Name)"
return $true
}
} catch {}
if (Test-Path -LiteralPath $tempFile2) { Remove-Item -LiteralPath $tempFile2 -Force }
}
} elseif (Test-Path -LiteralPath $tempFile) {
Remove-Item -LiteralPath $tempFile -Force
}
# If ffmpeg is not available, try binary patch for CRC-only fix
$needsReencode, $reason = Test-Mp3NeedsReencode -FilePath $File.FullName
if ($needsReencode -and $reason -match 'CRC') {
# Binary patch: flip the protection bit from 0 to 1 in each MPEG frame
try {
$bytes = [System.IO.File]::ReadAllBytes($File.FullName)
$patched = 0
# Find ID3v2 tag end
$audioOffset = 0
if ($bytes[0] -eq 0x49 -and $bytes[1] -eq 0x44 -and $bytes[2] -eq 0x33) {
$tagSize = ($bytes[6] -shl 21) -bor ($bytes[7] -shl 14) -bor ($bytes[8] -shl 7) -bor $bytes[9]
$audioOffset = $tagSize + 10
}
for ($i = $audioOffset; $i -lt $bytes.Length - 4; $i++) {
if ($bytes[$i] -eq 0xFF -and ($bytes[$i+1] -band 0xE0) -eq 0xE0) {
if (($bytes[$i+1] -band 0x01) -eq 0) {
# CRC bit is 0, flip it to 1 (no CRC)
$bytes[$i+1] = $bytes[$i+1] -bor 0x01
$patched++
}
# Skip past this frame
$b2 = $bytes[$i+2]
$bitrateIdx = ($b2 -shr 4) -band 0x0F
$br = $bitrateTable1L3[$bitrateIdx]
if ($br -gt 0) {
$padding = ($b2 -shr 1) -band 0x01
# Approximate frame skip (note: if CRC was present, frame had 2 extra bytes)
$frameSize = [int]([Math]::Floor(144.0 * $br * 1000 / 44100)) + $padding
# After removing CRC, each frame is 2 bytes shorter, but we're patching in-place
# so we just skip the original frame size minus the CRC
$i += $frameSize - 1
} else {
$i += 417
}
}
}
if ($patched -gt 0) {
[System.IO.File]::WriteAllBytes($File.FullName, $bytes)
Write-Host " Patched (CRC flag removed in $patched frames): $($File.Name)"
return $true
}
} catch {
# Binary patch failed
}
}
return $false
}
# Check and sanitize MP3 files for WoW 1.12 compatibility
Write-Host ""
Write-Host "Scanning MP3 files for WoW 1.12 compatibility..."
$mp3Files = @(Get-ChildItem -LiteralPath $Media -Recurse -File |
Where-Object { $_.Extension -match '^(?i)\.mp3$' })
$sanitized = 0
$skipped = 0
$scanErrors = 0
$compatOk = 0
if ($mp3Files.Count -eq 0) {
Write-Host "No MP3 files found in Media folder."
} else {
Write-Host "Found $($mp3Files.Count) MP3 file(s) to check..."
foreach ($mp3 in $mp3Files) {
try {
$needsReencode, $reason = Test-Mp3NeedsReencode -FilePath $mp3.FullName
if ($needsReencode) {
Write-Host " Issue found: $($mp3.Name) - $reason"
try {
$fixed = Sanitize-Mp3File -File $mp3
if ($fixed) { $sanitized++ } else { $skipped++ }
} catch {
Write-Host " WARNING: Could not sanitize $($mp3.Name): $($_.Exception.Message)"
$skipped++
}
} else {
$compatOk++
}
} catch {
Write-Host " WARNING: Could not scan $($mp3.Name): $($_.Exception.Message)"
$scanErrors++
}
}
Write-Host ""
Write-Host " Compatible: $compatOk Sanitized: $sanitized Skipped: $skipped Errors: $scanErrors"
}
if ($sanitized -gt 0) {
Write-Host ""
Write-Host "Sanitized $sanitized MP3 file(s) for WoW 1.12 compatibility."
Write-Host "These files were re-encoded to work with the in-game FMOD 3.x music engine."
Write-Host ""
}
if ($skipped -gt 0) {
Write-Host ""
Write-Host "WARNING: $skipped MP3 file(s) could not be sanitized (ffmpeg not available?)."
Write-Host "These tracks may not play correctly in-game. Install ffmpeg and re-run."
Write-Host ""
}
if ($sanitized -eq 0 -and $skipped -eq 0 -and $mp3Files.Count -gt 0) {
Write-Host ""
Write-Host "All $($mp3Files.Count) MP3 file(s) passed compatibility check."
Write-Host ""
}
# -- Sanitize WAV/OGG files for WoW 1.12 compatibility -----------------------
# WoW 1.12's FMOD 3.x engine is very picky about WAV and OGG files too.
# Many WAV/OGG files that play fine in modern players will silently fail in-game.
#
# Known WAV issues:
# 1. 24-bit or 32-bit float samples (FMOD 3.x only supports 8-bit and 16-bit PCM)
# 2. Sample rates other than 44100 Hz or 22050 Hz
# 3. More than 2 channels (surround sound WAVs)
# 4. ADPCM or other compressed WAV formats
# 5. RIFF LIST/INFO chunks that can confuse FMOD
# 6. Very large WAV files that exceed FMOD's buffer
# 7. IEEE float format (format code 3) instead of PCM (format code 1)
#
# Known OGG issues:
# 1. Vorbis encoding at unusual bitrates or quality levels
# 2. OGG files with video streams or metadata that FMOD cannot parse
# 3. OGG files encoded with very high quality settings (-q10) that FMOD 3.x rejects
# 4. Non-standard channel configurations
# 5. Opus codec inside an OGG container (FMOD 3.x only supports Vorbis in OGG)
#
# The ONLY reliable fix is to re-encode with ffmpeg using settings known to
# produce FMOD 3.x-compatible output. WAV is re-encoded to 16-bit PCM at
# 44100 Hz stereo; OGG is re-encoded to Vorbis at quality 6 (~192kbps).
function Test-WavNeedsReencode {
param([string]$FilePath)
try {
$ffmpegExe = Find-Ffmpeg -Name 'ffprobe'
if ($ffmpegExe) {
$ffOutFile = [System.IO.Path]::GetTempFileName()
$ffErrFile = [System.IO.Path]::GetTempFileName()
$ffProc = Start-Process -FilePath $ffmpegExe -ArgumentList @('-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', $FilePath) -NoNewWindow -Wait -PassThru -RedirectStandardOutput $ffOutFile -RedirectStandardError $ffErrFile
Remove-Item -LiteralPath $ffErrFile -Force -ErrorAction SilentlyContinue
if ($ffProc.ExitCode -eq 0 -and (Test-Path -LiteralPath $ffOutFile -PathType Leaf)) {
$ffOut = Get-Content -LiteralPath $ffOutFile -Raw
Remove-Item -LiteralPath $ffOutFile -Force -ErrorAction SilentlyContinue
if ($ffOut) {
$ffJson = $ffOut | ConvertFrom-Json
foreach ($stream in $ffJson.streams) {
if ($stream.codec_type -eq 'audio') {
# Check sample format - must be s16 (signed 16-bit PCM)
if ($stream.sample_rate -and [int]$stream.sample_rate -ne 44100 -and [int]$stream.sample_rate -ne 22050) {
return $true, "Sample rate $($stream.sample_rate) Hz (FMOD 3.x requires 44100 or 22050 Hz)"
}
if ($stream.channels -and [int]$stream.channels -gt 2) {
return $true, "$($stream.channels) channels (FMOD 3.x supports max 2 channels)"
}
# Check codec - must be PCM (not ADPCM, float, etc.)
if ($stream.codec_name -and $stream.codec_name -ne 'pcm_s16le' -and $stream.codec_name -ne 'pcm_s16be' -and $stream.codec_name -ne 'pcm_u8' -and $stream.codec_name -ne 'pcm_s24le' -and $stream.codec_name -ne 'pcm_f32le' -and $stream.codec_name -ne 'adpcm_ms' -and $stream.codec_name -ne 'adpcm_ima_wav') {
# Not a standard PCM codec
} elseif ($stream.codec_name -eq 'pcm_s24le') {
return $true, '24-bit PCM (FMOD 3.x only supports 8-bit and 16-bit)'
} elseif ($stream.codec_name -eq 'pcm_f32le' -or $stream.codec_name -eq 'pcm_f64le') {
return $true, 'Float PCM format (FMOD 3.x only supports integer PCM)'
} elseif ($stream.codec_name -match 'adpcm') {
return $true, 'ADPCM compressed WAV (FMOD 3.x may not support this codec)'
}
# Check bit depth via bits_per_sample if available
if ($stream.bits_per_sample -and [int]$stream.bits_per_sample -gt 16 -and [int]$stream.bits_per_sample -ne 0) {
return $true, "$($stream.bits_per_sample)-bit audio (FMOD 3.x only supports 8-bit and 16-bit)"
}
}
}
}
} else {
Remove-Item -LiteralPath $ffOutFile -Force -ErrorAction SilentlyContinue
}
}
# Fallback: check WAV header bytes manually
$bytes = [System.IO.File]::ReadAllBytes($FilePath)
if ($bytes.Length -lt 44) { return $false, 'File too small' }
# Check RIFF header
if ($bytes[0] -ne 0x52 -or $bytes[1] -ne 0x49 -or $bytes[2] -ne 0x46 -or $bytes[3] -ne 0x46) {
return $false, 'Not a valid RIFF/WAV file'
}
# Find the 'fmt ' chunk
$fmtOffset = -1
for ($i = 12; $i -lt [Math]::Min($bytes.Length - 24, 4096); $i++) {
if ($bytes[$i] -eq 0x66 -and $bytes[$i+1] -eq 0x6D -and $bytes[$i+2] -eq 0x74 -and $bytes[$i+3] -eq 0x20) {
$fmtOffset = $i + 8 # Skip past 'fmt ' and chunk size
break
}
}
if ($fmtOffset -ge 0 -and $fmtOffset + 16 -le $bytes.Length) {
# Parse format fields (little-endian)
$audioFormat = $bytes[$fmtOffset] -bor ($bytes[$fmtOffset+1] -shl 8)
$numChannels = $bytes[$fmtOffset+2] -bor ($bytes[$fmtOffset+3] -shl 8)
$sampleRate = $bytes[$fmtOffset+4] -bor ($bytes[$fmtOffset+5] -shl 8) -bor ($bytes[$fmtOffset+6] -shl 16) -bor ($bytes[$fmtOffset+7] -shl 24)
$bitsPerSample = $bytes[$fmtOffset+14] -bor ($bytes[$fmtOffset+15] -shl 8)
if ($audioFormat -ne 1) {
return $true, "Non-PCM format code $audioFormat (FMOD 3.x only supports PCM format code 1)"
}
if ($bitsPerSample -gt 16) {
return $true, "$bitsPerSample-bit audio (FMOD 3.x only supports 8-bit and 16-bit)"
}
if ($numChannels -gt 2) {
return $true, "$numChannels channels (FMOD 3.x supports max 2 channels)"
}
if ($sampleRate -ne 44100 -and $sampleRate -ne 22050 -and $sampleRate -ne 11025) {
return $true, "Sample rate $sampleRate Hz (FMOD 3.x requires 11025/22050/44100 Hz)"
}
}
return $false, 'Compatible'
}
catch {
return $true, "Error analyzing file: $($_.Exception.Message)"
}
}
function Test-OggNeedsReencode {
param([string]$FilePath)
try {
$ffmpegExe = Find-Ffmpeg -Name 'ffprobe'
if ($ffmpegExe) {
$ffOutFile = [System.IO.Path]::GetTempFileName()
$ffErrFile = [System.IO.Path]::GetTempFileName()
$ffProc = Start-Process -FilePath $ffmpegExe -ArgumentList @('-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', $FilePath) -NoNewWindow -Wait -PassThru -RedirectStandardOutput $ffOutFile -RedirectStandardError $ffErrFile
Remove-Item -LiteralPath $ffErrFile -Force -ErrorAction SilentlyContinue
if ($ffProc.ExitCode -eq 0 -and (Test-Path -LiteralPath $ffOutFile -PathType Leaf)) {
$ffOut = Get-Content -LiteralPath $ffOutFile -Raw
Remove-Item -LiteralPath $ffOutFile -Force -ErrorAction SilentlyContinue
if ($ffOut) {
$ffJson = $ffOut | ConvertFrom-Json
foreach ($stream in $ffJson.streams) {
if ($stream.codec_type -eq 'audio') {
# OGG must use Vorbis codec (not Opus, FLAC, etc.)
if ($stream.codec_name -and $stream.codec_name -ne 'vorbis') {
return $true, "Non-Vorbis codec '$($stream.codec_name)' in OGG container (FMOD 3.x only supports Vorbis)"
}
if ($stream.channels -and [int]$stream.channels -gt 2) {
return $true, "$($stream.channels) channels (FMOD 3.x supports max 2 channels)"
}
if ($stream.sample_rate -and [int]$stream.sample_rate -ne 44100 -and [int]$stream.sample_rate -ne 22050) {
return $true, "Sample rate $($stream.sample_rate) Hz (FMOD 3.x prefers 44100 or 22050 Hz)"
}
}
if ($stream.codec_type -eq 'video') {
return $true, "Embedded video stream in OGG (FMOD 3.x cannot handle)"
}
}
}
} else {
Remove-Item -LiteralPath $ffOutFile -Force -ErrorAction SilentlyContinue
}
}
return $false, 'Compatible'
}
catch {
return $true, "Error analyzing file: $($_.Exception.Message)"
}
}
function Sanitize-WavFile {
param([System.IO.FileInfo]$File)
$tempFile = $File.FullName + '.sanitize.tmp'
# Re-encode with ffmpeg: 16-bit PCM, 44100 Hz, stereo, strip metadata
$ffmpegExe = Find-Ffmpeg -Name 'ffmpeg'
try {
if ($ffmpegExe) {
$stderrFile = [System.IO.Path]::GetTempFileName()
$proc = Start-Process -FilePath $ffmpegExe -ArgumentList @('-y', '-i', $File.FullName, '-vn', '-c:a', 'pcm_s16le', '-ac', '2', '-ar', '44100', '-map_metadata', '-1', $tempFile) -NoNewWindow -Wait -PassThru -RedirectStandardError $stderrFile
Remove-Item -LiteralPath $stderrFile -Force -ErrorAction SilentlyContinue
if ($proc.ExitCode -ne 0) {
Write-Host " ffmpeg exited with code $($proc.ExitCode) for $($File.Name), trying alternate settings..."
}
}
} catch {
Write-Host " ffmpeg re-encode failed for $($File.Name): $($_.Exception.Message)"
}
if ((Test-Path -LiteralPath $tempFile -PathType Leaf) -and ((Get-Item -LiteralPath $tempFile).Length -gt 0)) {
Move-Item -LiteralPath $tempFile -Destination $File.FullName -Force
Write-Host " Sanitized (re-encoded to 16-bit PCM 44100Hz stereo for WoW 1.12): $($File.Name)"
return $true
} elseif (Test-Path -LiteralPath $tempFile) {
Remove-Item -LiteralPath $tempFile -Force
}
return $false
}
function Sanitize-OggFile {
param([System.IO.FileInfo]$File)
$tempFile = $File.FullName + '.sanitize.tmp'
# Re-encode with ffmpeg: Vorbis codec, quality 6 (~192kbps), 44100 Hz, stereo
$ffmpegExe = Find-Ffmpeg -Name 'ffmpeg'
try {
if ($ffmpegExe) {
$stderrFile = [System.IO.Path]::GetTempFileName()
$proc = Start-Process -FilePath $ffmpegExe -ArgumentList @('-y', '-i', $File.FullName, '-vn', '-c:a', 'libvorbis', '-q:a', '6', '-ac', '2', '-ar', '44100', '-map_metadata', '-1', $tempFile) -NoNewWindow -Wait -PassThru -RedirectStandardError $stderrFile
Remove-Item -LiteralPath $stderrFile -Force -ErrorAction SilentlyContinue
if ($proc.ExitCode -ne 0) {
Write-Host " ffmpeg exited with code $($proc.ExitCode) for $($File.Name), trying alternate settings..."
}
}
} catch {
Write-Host " ffmpeg re-encode failed for $($File.Name): $($_.Exception.Message)"
}
if ((Test-Path -LiteralPath $tempFile -PathType Leaf) -and ((Get-Item -LiteralPath $tempFile).Length -gt 0)) {
Move-Item -LiteralPath $tempFile -Destination $File.FullName -Force
Write-Host " Sanitized (re-encoded to Vorbis q6 44100Hz stereo for WoW 1.12): $($File.Name)"
return $true
} elseif (Test-Path -LiteralPath $tempFile) {
Remove-Item -LiteralPath $tempFile -Force
}
return $false
}
# Check and sanitize WAV files for WoW 1.12 compatibility
Write-Host ""
Write-Host "Scanning WAV files for WoW 1.12 compatibility..."
$wavFiles = @(Get-ChildItem -LiteralPath $Media -Recurse -File |
Where-Object { $_.Extension -match '^(?i)\.wav$' })
$wavSanitized = 0
$wavSkipped = 0
$wavCompatOk = 0
if ($wavFiles.Count -eq 0) {
Write-Host "No WAV files found in Media folder."
} else {
Write-Host "Found $($wavFiles.Count) WAV file(s) to check..."
foreach ($wav in $wavFiles) {
try {
$needsReencode, $reason = Test-WavNeedsReencode -FilePath $wav.FullName
if ($needsReencode) {
Write-Host " Issue found: $($wav.Name) - $reason"
try {
$fixed = Sanitize-WavFile -File $wav
if ($fixed) { $wavSanitized++ } else { $wavSkipped++ }
} catch {
Write-Host " WARNING: Could not sanitize $($wav.Name): $($_.Exception.Message)"
$wavSkipped++
}
} else {
$wavCompatOk++
}
} catch {
Write-Host " WARNING: Could not scan $($wav.Name): $($_.Exception.Message)"
$wavSkipped++
}
}
Write-Host ""
Write-Host " WAV Compatible: $wavCompatOk Sanitized: $wavSanitized Skipped: $wavSkipped"
}
if ($wavSanitized -gt 0) {
Write-Host ""
Write-Host "Sanitized $wavSanitized WAV file(s) for WoW 1.12 compatibility."
Write-Host "These files were re-encoded to 16-bit PCM 44100Hz stereo."
Write-Host ""
}
if ($wavSkipped -gt 0) {
Write-Host ""
Write-Host "WARNING: $wavSkipped WAV file(s) could not be sanitized (ffmpeg not available?)."
Write-Host "These tracks may not play correctly in-game. Install ffmpeg and re-run."
Write-Host ""
}
if ($wavSanitized -eq 0 -and $wavSkipped -eq 0 -and $wavFiles.Count -gt 0) {
Write-Host ""
Write-Host "All $($wavFiles.Count) WAV file(s) passed compatibility check."
Write-Host ""
}
# Check and sanitize OGG files for WoW 1.12 compatibility
Write-Host ""
Write-Host "Scanning OGG files for WoW 1.12 compatibility..."
$oggFiles = @(Get-ChildItem -LiteralPath $Media -Recurse -File |
Where-Object { $_.Extension -match '^(?i)\.ogg$' })
$oggSanitized = 0
$oggSkipped = 0
$oggCompatOk = 0
if ($oggFiles.Count -eq 0) {
Write-Host "No OGG files found in Media folder."
} else {
Write-Host "Found $($oggFiles.Count) OGG file(s) to check..."
foreach ($ogg in $oggFiles) {
try {
$needsReencode, $reason = Test-OggNeedsReencode -FilePath $ogg.FullName
if ($needsReencode) {
Write-Host " Issue found: $($ogg.Name) - $reason"
try {
$fixed = Sanitize-OggFile -File $ogg
if ($fixed) { $oggSanitized++ } else { $oggSkipped++ }
} catch {
Write-Host " WARNING: Could not sanitize $($ogg.Name): $($_.Exception.Message)"
$oggSkipped++
}
} else {
$oggCompatOk++
}
} catch {
Write-Host " WARNING: Could not scan $($ogg.Name): $($_.Exception.Message)"
$oggSkipped++
}
}
Write-Host ""
Write-Host " OGG Compatible: $oggCompatOk Sanitized: $oggSanitized Skipped: $oggSkipped"
}
if ($oggSanitized -gt 0) {
Write-Host ""
Write-Host "Sanitized $oggSanitized OGG file(s) for WoW 1.12 compatibility."
Write-Host "These files were re-encoded to Vorbis q6 44100Hz stereo."
Write-Host ""
}
if ($oggSkipped -gt 0) {
Write-Host ""
Write-Host "WARNING: $oggSkipped OGG file(s) could not be sanitized (ffmpeg not available?)."
Write-Host "These tracks may not play correctly in-game. Install ffmpeg and re-run."
Write-Host ""
}
if ($oggSanitized -eq 0 -and $oggSkipped -eq 0 -and $oggFiles.Count -gt 0) {
Write-Host ""
Write-Host "All $($oggFiles.Count) OGG file(s) passed compatibility check."
Write-Host ""
}
# -- Rename files with special characters for WoW 1.12 compatibility ----------
# WoW 1.12's FMOD engine may fail to play files whose names contain characters
# like apostrophes ('), parentheses, or other special symbols. This step
# renames such files by replacing problematic characters with underscores.
# Only the physical filename is changed; the track name in the playlist
# preserves the original display name.
$problematicChars = @("'", '"', '(', ')', '[', ']', '{', '}', '!', '&', '#', '%', '@', '+', '=', ',', ';')
$renamed = 0
$allMediaFiles = @(Get-ChildItem -LiteralPath $Media -Recurse -File |
Where-Object { $_.Extension -match '^(?i)\.(mp3|wav|ogg)$' })
foreach ($mf in $allMediaFiles) {
$needsRename = $false
foreach ($ch in $problematicChars) {
if ($mf.Name.Contains($ch)) {
$needsRename = $true
break
}
}
if ($needsRename) {
$newName = $mf.Name
foreach ($ch in $problematicChars) {
$newName = $newName.Replace($ch, '_')
}
# Remove double underscores and trailing/leading underscores
while ($newName.Contains('__')) { $newName = $newName.Replace('__', '_') }
$newPath = Join-Path $mf.DirectoryName $newName
if ($newPath -ne $mf.FullName -and -not (Test-Path -LiteralPath $newPath)) {
try {
Rename-Item -LiteralPath $mf.FullName -NewName $newName -Force
Write-Host " Renamed: $($mf.Name) -> $newName"
$renamed++
} catch {
Write-Host " WARNING: Could not rename $($mf.Name): $_"
}
}
}
}
if ($renamed -gt 0) {
Write-Host ""
Write-Host "Renamed $renamed file(s) -- replaced special characters for WoW 1.12 path compatibility."
Write-Host ""
}
# -- Scan Media folder -------------------------------------------------------
$files = @(Get-ChildItem -LiteralPath $Media -Recurse -File |
Where-Object { $_.Extension -match '^(?i)\.(mp3|wav|ogg)$' } |
Sort-Object -Property Name)
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add('RelationshipsJukeboxAutoPlaylist = {}') | Out-Null
$lines.Add('RelationshipsJukeboxAutoPlaylist.tracks = {') | Out-Null
$defaultCount = 0
foreach ($file in $files) {
# Build the WoW-relative path (always backslashes for consistency)
$relative = $file.FullName.Substring($Root.Length).TrimStart('\', '/')
$relative = $relative.Replace('/', '\')
$luaPath = Escape-LuaString(($AddonPrefix + $relative))
$name = Escape-LuaString($file.BaseName)
$duration = Get-TrackDurationSeconds -File $file
if ($duration -eq $DEFAULT_DURATION) {
$defaultCount++
}
$fmtLuaEntry = ' {{ path = "{0}", name = "{1}", duration = {2} }},'
$lines.Add(($fmtLuaEntry -f $luaPath, $name, $duration)) | Out-Null
}
$lines.Add('}') | Out-Null
# -- Write AutoPlaylist.lua (UTF-8 no BOM) -----------------------------------
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllLines($Out, $lines, $utf8NoBom)
# -- Summary -----------------------------------------------------------------
Write-Host "Generated: $Out"
Write-Host "Tracks: $($files.Count)"
if ($defaultCount -gt 0) {
Write-Host ""
Write-Host "$defaultCount track(s) using estimated/default duration $($DEFAULT_DURATION)s."
Write-Host "Autoplay and Shuffle will still work -- the addon advances after the estimated time."
} else {
Write-Host 'All track durations were captured from metadata successfully.'
}
Write-Host ''
Write-Host 'Next step: type /reload in game or restart the client.'
# -- Cleanup COM objects -----------------------------------------------------
if ($wmp) { try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($wmp) } catch {} }
if ($shell) { try { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($shell) } catch {} }