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 "" } # -- 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 {} }