1358 lines
58 KiB
PowerShell
1358 lines
58 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 ""
|
|
}
|
|
|
|
# -- 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 {} }
|