2378 lines
104 KiB
PowerShell
2378 lines
104 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
|
|
|
|
# -- 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.
|
|
#
|
|
# IMPORTANT: This rename step runs BEFORE the MP3/WAV/OGG compatibility scan
|
|
# so that ffmpeg and the binary analysis functions can access files without
|
|
# special characters in their paths. Characters like apostrophes and quotes
|
|
# can break PowerShell's COM object calls and ffmpeg argument parsing.
|
|
|
|
$problematicChars = @("'", '"', '(', ')', '[', ']', '{', '}', '!', '&', '#', '%', '@', '+', '=', ',', ';')
|
|
$renamed = 0
|
|
$renameSkipped = 0
|
|
$allMediaFiles = @(Get-ChildItem -LiteralPath $Media -Recurse -File |
|
|
Where-Object { $_.Extension -match '^(?i)\.(mp3|wav|ogg)$' -and $_.Name -notmatch '\.sanitize\.tmp\.' })
|
|
|
|
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) {
|
|
if (-not (Test-Path -LiteralPath $newPath)) {
|
|
# Target doesn't exist - safe to rename
|
|
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): $_"
|
|
$renameSkipped++
|
|
}
|
|
} else {
|
|
# Target already exists - this is likely a duplicate file from a previous
|
|
# rename or download. Delete the old problematic-name file since the
|
|
# already-renamed file takes precedence.
|
|
try {
|
|
Remove-Item -LiteralPath $mf.FullName -Force
|
|
Write-Host " Removed duplicate (already renamed): $($mf.Name) -> $newName (kept existing)"
|
|
$renameSkipped++
|
|
} catch {
|
|
Write-Host " WARNING: Could not remove duplicate $($mf.Name): $_"
|
|
$renameSkipped++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($renamed -gt 0) {
|
|
Write-Host ""
|
|
Write-Host "Renamed $renamed file(s) -- replaced special characters for WoW 1.12 path compatibility."
|
|
Write-Host ""
|
|
}
|
|
if ($renameSkipped -gt 0) {
|
|
Write-Host ""
|
|
Write-Host "$renameSkipped file(s) skipped during rename (duplicate with sanitized name already existed)."
|
|
Write-Host ""
|
|
}
|
|
|
|
# -- Rename numeric-only MP3 filenames using embedded ID3 title --------------
|
|
# Some MP3s have filenames that are just numbers (e.g. "01.mp3", "1234.mp3",
|
|
# "01 02 03.mp3"). These are useless in the playlist UI. When we detect such
|
|
# a file, read the embedded ID3 title tag via Shell.Application and rename the
|
|
# file to "<Title>.mp3" (sanitized for WoW 1.12 path compatibility).
|
|
|
|
function Get-Mp3EmbeddedTitle {
|
|
param([Parameter(Mandatory=$true)][string]$FilePath)
|
|
try {
|
|
$shell = New-Object -ComObject Shell.Application
|
|
$folder = $shell.Namespace((Split-Path -Parent $FilePath))
|
|
if ($null -eq $folder) { return $null }
|
|
$item = $folder.ParseName((Split-Path -Leaf $FilePath))
|
|
if ($null -eq $item) { return $null }
|
|
# Property index 21 = Title on Windows; fall back to scanning if empty.
|
|
$title = $folder.GetDetailsOf($item, 21)
|
|
if ([string]::IsNullOrWhiteSpace($title)) {
|
|
for ($i = 0; $i -lt 400; $i++) {
|
|
$header = $folder.GetDetailsOf($null, $i)
|
|
if ($header -eq 'Title') {
|
|
$title = $folder.GetDetailsOf($item, $i)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($title)) { return $null }
|
|
return $title.Trim()
|
|
} catch {
|
|
return $null
|
|
}
|
|
}
|
|
|
|
$numericRenamed = 0
|
|
$numericSkipped = 0
|
|
$mp3Files = @(Get-ChildItem -LiteralPath $Media -Recurse -File |
|
|
Where-Object { $_.Extension -match '^(?i)\.mp3$' -and $_.Name -notmatch '\.sanitize\.tmp\.' })
|
|
|
|
foreach ($mf in $mp3Files) {
|
|
$base = [System.IO.Path]::GetFileNameWithoutExtension($mf.Name)
|
|
# "Only numbers": digits, optionally separated by spaces / dots / dashes / underscores.
|
|
if ($base -notmatch '^[\d\s\.\-_]+$' -or $base -notmatch '\d') { continue }
|
|
|
|
$title = Get-Mp3EmbeddedTitle -FilePath $mf.FullName
|
|
if ([string]::IsNullOrWhiteSpace($title)) {
|
|
Write-Host " Skipped (no embedded title): $($mf.Name)"
|
|
$numericSkipped++
|
|
continue
|
|
}
|
|
|
|
# Sanitize the title for filesystem + WoW 1.12 compatibility.
|
|
$safeTitle = $title
|
|
foreach ($ch in $problematicChars) { $safeTitle = $safeTitle.Replace($ch, '_') }
|
|
foreach ($ch in [System.IO.Path]::GetInvalidFileNameChars()) {
|
|
$safeTitle = $safeTitle.Replace([string]$ch, '_')
|
|
}
|
|
while ($safeTitle.Contains('__')) { $safeTitle = $safeTitle.Replace('__', '_') }
|
|
$safeTitle = $safeTitle.Trim(' ', '_', '.')
|
|
|
|
if ([string]::IsNullOrWhiteSpace($safeTitle)) {
|
|
Write-Host " Skipped (title sanitized to empty): $($mf.Name)"
|
|
$numericSkipped++
|
|
continue
|
|
}
|
|
|
|
$newName = "$safeTitle.mp3"
|
|
$newPath = Join-Path $mf.DirectoryName $newName
|
|
if ($newPath -eq $mf.FullName) { continue }
|
|
|
|
# Avoid collisions by appending a numeric suffix.
|
|
if (Test-Path -LiteralPath $newPath) {
|
|
$n = 2
|
|
do {
|
|
$candidate = Join-Path $mf.DirectoryName ("$safeTitle ($n).mp3")
|
|
$n++
|
|
} while (Test-Path -LiteralPath $candidate)
|
|
$newPath = $candidate
|
|
$newName = Split-Path -Leaf $candidate
|
|
}
|
|
|
|
try {
|
|
Rename-Item -LiteralPath $mf.FullName -NewName $newName -Force
|
|
Write-Host " Renamed (numeric -> title): $($mf.Name) -> $newName"
|
|
$numericRenamed++
|
|
} catch {
|
|
Write-Host " WARNING: Could not rename $($mf.Name): $_"
|
|
$numericSkipped++
|
|
}
|
|
}
|
|
|
|
if ($numericRenamed -gt 0) {
|
|
Write-Host ""
|
|
Write-Host "Renamed $numericRenamed numeric-only MP3 file(s) using embedded ID3 title."
|
|
Write-Host ""
|
|
}
|
|
if ($numericSkipped -gt 0) {
|
|
Write-Host ""
|
|
Write-Host "$numericSkipped numeric-only MP3 file(s) skipped (no title / collision / error)."
|
|
Write-Host ""
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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'
|
|
|
|
# -- ffprobe result cache (MAJOR SPEEDUP) -----------------------------------
|
|
# Instead of calling ffprobe multiple times per file (once for duration, once
|
|
# for video stream detection, once after re-encoding, etc.), we call it ONCE
|
|
# with -show_format -show_streams and cache the parsed JSON object per file path.
|
|
# This eliminates 1-3 redundant ffprobe process launches per file.
|
|
$script:ffprobeCache = @{}
|
|
|
|
function Get-FfprobeData {
|
|
param([string]$FilePath)
|
|
if ($script:ffprobeCache.ContainsKey($FilePath)) {
|
|
return $script:ffprobeCache[$FilePath]
|
|
}
|
|
$exe = Find-Ffmpeg -Name 'ffprobe'
|
|
if (-not $exe) {
|
|
$script:ffprobeCache[$FilePath] = $null
|
|
return $null
|
|
}
|
|
try {
|
|
# Use & call operator instead of Start-Process — much faster
|
|
# (no process-creation overhead, proper argument quoting built-in)
|
|
$raw = & $exe '-v' 'quiet' '-print_format' 'json' '-show_format' '-show_streams' $FilePath 2>$null
|
|
if ($LASTEXITCODE -eq 0 -and $raw) {
|
|
$json = $raw | ConvertFrom-Json
|
|
$script:ffprobeCache[$FilePath] = $json
|
|
return $json
|
|
}
|
|
} catch {
|
|
# ffprobe failed — cache null to avoid retrying
|
|
}
|
|
$script:ffprobeCache[$FilePath] = $null
|
|
return $null
|
|
}
|
|
|
|
# Clear the ffprobe cache (call after a file has been re-encoded in place)
|
|
function Clear-FfprobeCache {
|
|
param([string]$FilePath)
|
|
if ($script:ffprobeCache.ContainsKey($FilePath)) {
|
|
$script:ffprobeCache.Remove($FilePath)
|
|
}
|
|
}
|
|
|
|
# -- ffmpeg/ffprobe call helpers using & operator (FASTER than Start-Process) --
|
|
# PowerShell's & (call) operator properly quotes arguments containing spaces,
|
|
# so we no longer need Build-QuotedArgs or Start-Process for most calls.
|
|
# The & operator also avoids the ~200-400ms process-launch overhead that
|
|
# Start-Process imposes on every invocation.
|
|
|
|
function Invoke-Ffprobe {
|
|
param([string[]]$Arguments)
|
|
$exe = Find-Ffmpeg -Name 'ffprobe'
|
|
if ($exe) {
|
|
return & $exe @Arguments
|
|
}
|
|
return $null
|
|
}
|
|
|
|
function Invoke-Ffmpeg {
|
|
param([string[]]$Arguments)
|
|
$exe = Find-Ffmpeg -Name 'ffmpeg'
|
|
if ($exe) {
|
|
return & $exe @Arguments
|
|
}
|
|
return $null
|
|
}
|
|
|
|
# -- Build a properly-quoted argument string for Start-Process ---------------
|
|
# NOTE: Start-Process is now ONLY used for ffmpeg re-encoding where we need
|
|
# -RedirectStandardError for diagnostics. All other ffprobe/ffmpeg calls
|
|
# use the faster & operator via Invoke-Ffprobe / Invoke-Ffmpeg above.
|
|
#
|
|
# PowerShell's Start-Process -ArgumentList with an array does NOT automatically
|
|
# quote elements containing spaces. When a file path like
|
|
# "G:\Music\01 - Pantera - Walk.mp3" is passed as an array element,
|
|
# Start-Process splits it at the spaces, and ffmpeg receives multiple broken
|
|
# tokens instead of one quoted path. This function builds a single argument
|
|
# string where every token that contains spaces is wrapped in double-quotes.
|
|
#
|
|
# Usage: Start-Process -FilePath $exe -ArgumentList (Build-QuotedArgs @(...))
|
|
function Build-QuotedArgs {
|
|
param([string[]]$ArgumentArray)
|
|
$parts = @()
|
|
foreach ($arg in $ArgumentArray) {
|
|
if ($arg -match '\s') {
|
|
# Argument contains spaces -- wrap in double-quotes.
|
|
# Escape any embedded double-quotes first.
|
|
$escaped = $arg.Replace('"', '\"')
|
|
$parts += "`"$escaped`""
|
|
} else {
|
|
$parts += $arg
|
|
}
|
|
}
|
|
return $parts -join ' '
|
|
}
|
|
|
|
# -- Compatibility cache (MAJOR SPEEDUP on re-runs) -------------------------
|
|
# After the first run, each file's compatibility status is cached in a
|
|
# .compat_cache file next to the script. On subsequent runs, files whose
|
|
# last-modified timestamp and size haven't changed are skipped entirely,
|
|
# cutting scan time from minutes to seconds for large libraries.
|
|
$compatCachePath = Join-Path $Root 'mp3_compat_cache.txt'
|
|
$script:compatCache = @{}
|
|
|
|
function Load-CompatCache {
|
|
if (Test-Path -LiteralPath $compatCachePath -PathType Leaf) {
|
|
try {
|
|
$lines = [System.IO.File]::ReadAllLines($compatCachePath)
|
|
foreach ($line in $lines) {
|
|
# Format: LastWriteTime|Length|Status|Reason
|
|
# Status: OK = compatible, FIX = was sanitized
|
|
$parts = $line -split '\|'
|
|
if ($parts.Count -ge 4) {
|
|
$key = "$($parts[0])|$($parts[1])"
|
|
$script:compatCache[$key] = @($parts[2], $parts[3])
|
|
}
|
|
}
|
|
} catch {
|
|
$script:compatCache = @{}
|
|
}
|
|
}
|
|
}
|
|
|
|
function Save-CompatCache {
|
|
try {
|
|
$lines = @()
|
|
foreach ($key in $script:compatCache.Keys) {
|
|
$val = $script:compatCache[$key]
|
|
$lines += "$key|$($val[0])|$($val[1])"
|
|
}
|
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
[System.IO.File]::WriteAllLines($compatCachePath, $lines, $utf8NoBom)
|
|
} catch {
|
|
# Silently ignore cache save errors
|
|
}
|
|
}
|
|
|
|
function Get-CompatCacheEntry {
|
|
param([System.IO.FileInfo]$File)
|
|
$key = "$($File.LastWriteTimeUtc.Ticks)|$($File.Length)"
|
|
if ($script:compatCache.ContainsKey($key)) {
|
|
return $script:compatCache[$key]
|
|
}
|
|
return $null
|
|
}
|
|
|
|
function Set-CompatCacheEntry {
|
|
param([System.IO.FileInfo]$File, [string]$Status, [string]$Reason)
|
|
$key = "$($File.LastWriteTimeUtc.Ticks)|$($File.Length)"
|
|
$script:compatCache[$key] = @($Status, $Reason)
|
|
}
|
|
|
|
# Load cache at startup
|
|
Load-CompatCache
|
|
|
|
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 ffprobe cache FIRST (avoids redundant process launch) ---
|
|
$ffData = Get-FfprobeData -FilePath $File.FullName
|
|
if ($ffData -and $ffData.format -and $ffData.format.duration) {
|
|
$dur = [double]$ffData.format.duration
|
|
if ($dur -gt 0) { return [int][Math]::Ceiling($dur) }
|
|
}
|
|
|
|
# --- 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
|
|
}
|
|
}
|
|
|
|
# --- 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.
|
|
|
|
# -- Fast-path pre-check for obviously compatible files ----------------------
|
|
# If a file starts with 0xFF 0xFB (MPEG1 Layer3, no CRC, no ID3v2 tag) and
|
|
# has no ID3v1 tail tag, it's almost certainly FMOD 3.x-compatible. This
|
|
# avoids reading the entire file into memory and scanning 50 MPEG frames.
|
|
# Returns: $true if the file is obviously compatible (skip full scan),
|
|
# $false if the file needs the full Test-Mp3NeedsReencode check.
|
|
function Test-Mp3FastPathCompatible {
|
|
param([string]$FilePath)
|
|
try {
|
|
# Read only the first 4 bytes and last 128 bytes
|
|
$fs = [System.IO.File]::OpenRead($FilePath)
|
|
try {
|
|
if ($fs.Length -lt 4) { return $false }
|
|
|
|
# Check first 4 bytes for MPEG1 Layer3 no-CRC sync word
|
|
$head = New-Object byte[] 4
|
|
[void]$fs.Read($head, 0, 4)
|
|
|
|
# 0xFF 0xFB = MPEG1, Layer3, no CRC protection
|
|
# Also accept 0xFF 0xFA (MPEG1 Layer3 with CRC — needs full check)
|
|
# Also accept 0xFF 0xE3 etc. (MPEG2 — needs full check)
|
|
if ($head[0] -ne 0xFF -or ($head[1] -band 0xE0) -ne 0xE0) {
|
|
# Not starting with MPEG sync — might have ID3v2 tag
|
|
return $false
|
|
}
|
|
# Check: must be MPEG1 Layer3 no-CRC (0xFF 0xFBx where x has specific bits)
|
|
# Byte 1: bits = [sync1 sync2 sync3 ID1 ID0 Layer1 Layer0 Protection]
|
|
# MPEG1=11, Layer3=01, NoCRC=1 => 0xFF 0xFB
|
|
if ($head[1] -ne 0xFB) {
|
|
# Not MPEG1 Layer3 no-CRC — needs full check
|
|
return $false
|
|
}
|
|
|
|
# Check for ID3v1 tail tag (last 128 bytes start with 'TAG')
|
|
if ($fs.Length -ge 128) {
|
|
$fs.Seek(-128, [System.IO.SeekOrigin]::End) | Out-Null
|
|
$tail = New-Object byte[] 3
|
|
[void]$fs.Read($tail, 0, 3)
|
|
if ($tail[0] -eq 0x54 -and $tail[1] -eq 0x41 -and $tail[2] -eq 0x47) {
|
|
# Has ID3v1 tag — should strip it, needs full check
|
|
return $false
|
|
}
|
|
}
|
|
|
|
# Also check for video streams via ffprobe cache
|
|
$ffData = Get-FfprobeData -FilePath $FilePath
|
|
if ($ffData -and $ffData.streams) {
|
|
foreach ($stream in $ffData.streams) {
|
|
if ($stream.codec_type -eq 'video') {
|
|
return $false
|
|
}
|
|
}
|
|
}
|
|
|
|
# Fast-path: file starts with 0xFF 0xFB, no ID3v1, no video streams
|
|
return $true
|
|
} finally {
|
|
$fs.Close()
|
|
}
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
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
|
|
$hasLameTag = $false
|
|
$lameEncoder = ''
|
|
$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
|
|
# Scan for LAME encoder tag after the Info header fields
|
|
# Info header layout: 4 bytes 'Info' + 4 bytes flags + optional fields + LAME tag
|
|
# The LAME tag starts 120 bytes after the Info magic (after flags+frames+bytes+TOC+vbr_scale)
|
|
# Total Info header with all flags set = 4+4+4+4+100+4 = 120 bytes, then LAME tag
|
|
$lameOffset = $xingOffset + 120
|
|
if ($lameOffset + 4 -lt $bytes.Length) {
|
|
# Check for common encoder signatures: LAME, Lavc, Lavf, GOGO, etc.
|
|
# These appear as ASCII text at the start of the encoder tag field
|
|
$encBytes = $bytes[$lameOffset..($lameOffset+8)]
|
|
$encText = [System.Text.Encoding]::ASCII.GetString($encBytes).TrimEnd([char]0)
|
|
if ($encText -match '^(LAME|Lavc|Lavf|GOGO|Xing|Gogo|lame|REA[LC]|THOMS|QDesign)') {
|
|
$hasLameTag = $true
|
|
$lameEncoder = $encText
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$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
|
|
# Determine actual sample rate from MPEG header for accurate frame size
|
|
$sampleRates1 = @(44100, 48000, 32000, 0) # MPEG1
|
|
$sampleRates2 = @(22050, 24000, 16000, 0) # MPEG2
|
|
$sampleRates25 = @(11025, 12000, 8000, 0) # MPEG2.5
|
|
if ($mpegVersion -eq 3) { $curSampleRate = $sampleRates1[$sampleRateIdx] }
|
|
elseif ($mpegVersion -eq 2) { $curSampleRate = $sampleRates2[$sampleRateIdx] }
|
|
else { $curSampleRate = $sampleRates25[$sampleRateIdx] }
|
|
if ($curSampleRate -le 0) { $curSampleRate = 44100 } # fallback
|
|
$frameSize = [int]([Math]::Floor(144.0 * $br * 1000 / $curSampleRate)) + $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)'
|
|
}
|
|
|
|
# Info header with LAME encoder tag
|
|
# Many FMOD 3.x implementations (including WoW 1.12) can misparse the LAME
|
|
# delay/padding fields in the Info/LAME tag, causing the audio start offset
|
|
# to be calculated incorrectly. Re-encoding strips the Info/LAME tag entirely
|
|
# and produces a clean MPEG stream that FMOD can parse reliably.
|
|
if ($foundInfo -and $hasLameTag) {
|
|
return $true, "Info/LAME encoder tag found ($lameEncoder) - FMOD 3.x may misparse delay/padding fields"
|
|
}
|
|
|
|
# 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
|
|
# Uses the ffprobe cache instead of launching a separate process
|
|
$ffData = Get-FfprobeData -FilePath $FilePath
|
|
if ($ffData -and $ffData.streams) {
|
|
foreach ($stream in $ffData.streams) {
|
|
if ($stream.codec_type -eq 'video') {
|
|
return $true, "Embedded video/image stream (codec: $($stream.codec_name)) - FMOD 3.x cannot handle"
|
|
}
|
|
}
|
|
}
|
|
|
|
# Check for ID3v2 tag that doesn't have embedded art/unsync/extended header
|
|
# (those are already caught above). Even a "clean" ID3v2 tag can cause
|
|
# FMOD 3.x to add a small seek offset at the start of playback. Stripping
|
|
# it ensures the file starts directly with the MPEG sync word.
|
|
if ($hasId3v2 -and $audioOffset -gt 0) {
|
|
return $true, 'ID3v2 tag present (may cause seek offset in FMOD 3.x)'
|
|
}
|
|
|
|
return $false, 'Compatible'
|
|
}
|
|
catch {
|
|
return $true, "Error analyzing file: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
# -- Strip ALL tags from an MP3 file in a SINGLE pass (COMBINED for SPEED) ---
|
|
# After ffmpeg re-encodes an MP3 file, libmp3lame writes its own Info/LAME tag
|
|
# and ffmpeg may write a minimal ID3v2.4 header. Previously we called
|
|
# Strip-LameTag (reads entire file) then Strip-Id3v2Tag (reads entire file again),
|
|
# resulting in 2 full file reads + 2 full file writes. This combined function
|
|
# does everything in ONE read + ONE write, cutting I/O time in half.
|
|
#
|
|
# This function modifies the file IN PLACE and is safe to call on files that
|
|
# don't have any tags (it will detect and skip them).
|
|
# Returns: @($strippedLame, $strippedId3v2) - booleans indicating what was stripped.
|
|
function Strip-Mp3Tags {
|
|
param([string]$FilePath)
|
|
|
|
$strippedLame = $false
|
|
$strippedId3v2 = $false
|
|
|
|
try {
|
|
$bytes = [System.IO.File]::ReadAllBytes($FilePath)
|
|
if ($bytes.Length -lt 10) { return @($false, $false) }
|
|
|
|
# --- Step 1: Strip ID3v2 tag from the beginning ---
|
|
$id3v2TotalSize = 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]
|
|
$id3v2TotalSize = $tagSize + 10 # 10-byte header + tag body
|
|
if ($id3v2TotalSize -lt $bytes.Length) {
|
|
# Remove the ID3v2 tag bytes entirely by shifting the array
|
|
$newLen = $bytes.Length - $id3v2TotalSize
|
|
$shifted = New-Object byte[] $newLen
|
|
[Array]::Copy($bytes, $id3v2TotalSize, $shifted, 0, $newLen)
|
|
$bytes = $shifted
|
|
$strippedId3v2 = $true
|
|
}
|
|
}
|
|
|
|
# --- Step 2: Strip Info/LAME tag from first MPEG frame ---
|
|
if ($bytes.Length -ge 4) {
|
|
# Look for the first MPEG frame sync word
|
|
$frameStart = -1
|
|
for ($i = 0; $i -lt [Math]::Min($bytes.Length - 4, 4096); $i++) {
|
|
if ($bytes[$i] -eq 0xFF -and ($bytes[$i+1] -band 0xE0) -eq 0xE0) {
|
|
$frameStart = $i
|
|
break
|
|
}
|
|
}
|
|
|
|
if ($frameStart -ge 0) {
|
|
# Determine the Xing/Info header offset
|
|
$b1 = $bytes[$frameStart + 1]
|
|
$mpegVersion = ($b1 -shr 3) -band 0x03
|
|
$layer = ($b1 -shr 1) -band 0x03
|
|
$channelMode = ($bytes[$frameStart + 3] -shr 6) -band 0x03
|
|
|
|
if ($mpegVersion -eq 3 -and $layer -eq 1 -and $channelMode -ne 3) {
|
|
$xingOffset = $frameStart + 36
|
|
} elseif ($mpegVersion -eq 3 -and $layer -eq 1 -and $channelMode -eq 3) {
|
|
$xingOffset = $frameStart + 21
|
|
} elseif (($mpegVersion -eq 2 -or $mpegVersion -eq 0) -and $layer -eq 1) {
|
|
$xingOffset = $frameStart + 21
|
|
} else {
|
|
$xingOffset = -1
|
|
}
|
|
|
|
if ($xingOffset -ge 0 -and $xingOffset + 4 -lt $bytes.Length) {
|
|
$isInfo = ($bytes[$xingOffset] -eq 0x49 -and $bytes[$xingOffset+1] -eq 0x6E -and $bytes[$xingOffset+2] -eq 0x66 -and $bytes[$xingOffset+3] -eq 0x6F)
|
|
$isXing = ($bytes[$xingOffset] -eq 0x58 -and $bytes[$xingOffset+1] -eq 0x69 -and $bytes[$xingOffset+2] -eq 0x6E -and $bytes[$xingOffset+3] -eq 0x67)
|
|
|
|
if ($isInfo -or $isXing) {
|
|
# Calculate Info/Xing header end position
|
|
$headerFlags = ($bytes[$xingOffset+4] -shl 24) -bor ($bytes[$xingOffset+5] -shl 16) -bor ($bytes[$xingOffset+6] -shl 8) -bor $bytes[$xingOffset+7]
|
|
$headerEnd = $xingOffset + 8 # After magic + flags
|
|
if ($headerFlags -band 0x01) { $headerEnd += 4 } # Frames field
|
|
if ($headerFlags -band 0x02) { $headerEnd += 4 } # Bytes field
|
|
if ($headerFlags -band 0x04) { $headerEnd += 100 } # TOC
|
|
if ($headerFlags -band 0x08) { $headerEnd += 4 } # VBR scale
|
|
$headerEnd += 24 # LAME tag
|
|
|
|
# Zero out the entire Info/Xing + LAME tag area
|
|
for ($j = $xingOffset; $j -lt [Math]::Min($headerEnd, $bytes.Length); $j++) {
|
|
$bytes[$j] = 0x00
|
|
}
|
|
$strippedLame = $true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# --- Step 3: Strip ID3v1 tag from the end ---
|
|
$strippedId3v1 = $false
|
|
if ($bytes.Length -ge 128 -and $bytes[$bytes.Length - 128] -eq 0x54 -and $bytes[$bytes.Length - 127] -eq 0x41 -and $bytes[$bytes.Length - 126] -eq 0x47) {
|
|
$newBytes = New-Object byte[] ($bytes.Length - 128)
|
|
[Array]::Copy($bytes, 0, $newBytes, 0, $bytes.Length - 128)
|
|
$bytes = $newBytes
|
|
$strippedId3v1 = $true
|
|
}
|
|
|
|
# --- Write only if something was stripped ---
|
|
if ($strippedLame -or $strippedId3v2 -or $strippedId3v1) {
|
|
[System.IO.File]::WriteAllBytes($FilePath, $bytes)
|
|
# Clear ffprobe cache since file changed
|
|
Clear-FfprobeCache -FilePath $FilePath
|
|
return @($strippedLame, $strippedId3v2)
|
|
}
|
|
return @($false, $false)
|
|
} catch {
|
|
return @($false, $false)
|
|
}
|
|
}
|
|
|
|
# -- Legacy wrappers for backward compatibility with any code that calls them --
|
|
# These now delegate to Strip-Mp3Tags for the same single-pass performance.
|
|
function Strip-LameTag {
|
|
param([string]$FilePath)
|
|
$result = Strip-Mp3Tags -FilePath $FilePath
|
|
return $result[0]
|
|
}
|
|
|
|
function Strip-Id3v2Tag {
|
|
param([string]$FilePath)
|
|
$result = Strip-Mp3Tags -FilePath $FilePath
|
|
return $result[1]
|
|
}
|
|
|
|
|
|
function Sanitize-Mp3File {
|
|
param([System.IO.FileInfo]$File)
|
|
|
|
# Bitrate table for MPEG1 Layer III (used by the CRC binary-patch fallback)
|
|
$bitrateTable1L3 = @(0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0)
|
|
|
|
# Use a temp file with .mp3 extension so ffmpeg can auto-detect the output format.
|
|
# ffmpeg refuses to write to files with non-standard extensions (e.g. .sanitize.tmp).
|
|
$tempFile = $File.FullName + '.sanitize.tmp.mp3'
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Step 1: Try to re-encode with ffmpeg (primary method).
|
|
# Re-encoding produces a clean, FMOD 3.x-compatible MP3 with no CRC,
|
|
# no VBR, no Info/LAME tag, CBR 256kbps, simple stereo, no embedded art.
|
|
# -------------------------------------------------------------------------
|
|
$ffmpegExe = Find-Ffmpeg -Name 'ffmpeg'
|
|
$ffmpegSuccess = $false
|
|
|
|
if ($ffmpegExe) {
|
|
# Try primary re-encode: 256kbps CBR with error-tolerant input parsing.
|
|
try {
|
|
$stderrFile = [System.IO.Path]::GetTempFileName()
|
|
$proc = Start-Process -FilePath $ffmpegExe -ArgumentList (Build-QuotedArgs @('-y', '-err_detect', '+ignore_err', '-i', $File.FullName, '-vn', '-c:a', 'libmp3lame', '-b:a', '256k', '-ac', '2', '-ar', '44100', '-joint_stereo', '0', '-map_metadata', '-1', '-fflags', '+bitexact', '-flags:a', '+bitexact', $tempFile)) -NoNewWindow -Wait -PassThru -RedirectStandardError $stderrFile
|
|
|
|
# Save stderr output for diagnostics even on success
|
|
$stderrContent = $null
|
|
if (Test-Path -LiteralPath $stderrFile -PathType Leaf) {
|
|
$stderrContent = Get-Content -LiteralPath $stderrFile -Raw -ErrorAction SilentlyContinue
|
|
}
|
|
Remove-Item -LiteralPath $stderrFile -Force -ErrorAction SilentlyContinue
|
|
|
|
if ($proc.ExitCode -ne 0) {
|
|
Write-Host " ffmpeg (256kbps) exited with code $($proc.ExitCode) for $($File.Name)"
|
|
if ($stderrContent -and $stderrContent.Length -gt 0) {
|
|
$diag = $stderrContent.TrimEnd()
|
|
if ($diag.Length -gt 500) { $diag = $diag.Substring($diag.Length - 500) }
|
|
Write-Host " ffmpeg stderr (tail): $diag"
|
|
}
|
|
Write-Host " Trying fallback re-encode at 192kbps..."
|
|
} else {
|
|
$ffmpegSuccess = $true
|
|
}
|
|
} catch {
|
|
Write-Host " ffmpeg re-encode (256kbps) threw exception for $($File.Name): $($_.Exception.Message)"
|
|
}
|
|
|
|
# If primary re-encode failed, try fallback with lower bitrate.
|
|
if (-not $ffmpegSuccess -and -not (Test-Path -LiteralPath $tempFile -PathType Leaf)) {
|
|
try {
|
|
$tempFile = $File.FullName + '.sanitize.tmp.mp3'
|
|
$stderrFile2 = [System.IO.Path]::GetTempFileName()
|
|
$proc2 = Start-Process -FilePath $ffmpegExe -ArgumentList (Build-QuotedArgs @('-y', '-err_detect', '+ignore_err', '-i', $File.FullName, '-vn', '-c:a', 'libmp3lame', '-b:a', '192k', '-ac', '2', '-ar', '44100', '-joint_stereo', '0', '-map_metadata', '-1', '-fflags', '+bitexact', '-flags:a', '+bitexact', $tempFile)) -NoNewWindow -Wait -PassThru -RedirectStandardError $stderrFile2
|
|
$stderrContent2 = $null
|
|
if (Test-Path -LiteralPath $stderrFile2 -PathType Leaf) {
|
|
$stderrContent2 = Get-Content -LiteralPath $stderrFile2 -Raw -ErrorAction SilentlyContinue
|
|
}
|
|
Remove-Item -LiteralPath $stderrFile2 -Force -ErrorAction SilentlyContinue
|
|
|
|
if ($proc2.ExitCode -ne 0) {
|
|
Write-Host " ffmpeg (192kbps fallback) also exited with code $($proc2.ExitCode) for $($File.Name)"
|
|
if ($stderrContent2 -and $stderrContent2.Length -gt 0) {
|
|
$diag2 = $stderrContent2.TrimEnd()
|
|
if ($diag2.Length -gt 500) { $diag2 = $diag2.Substring($diag2.Length - 500) }
|
|
Write-Host " ffmpeg stderr (tail): $diag2"
|
|
}
|
|
} else {
|
|
$ffmpegSuccess = $true
|
|
}
|
|
} catch {
|
|
Write-Host " ffmpeg 192kbps fallback threw exception for $($File.Name): $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
# If ffmpeg produced a valid output file, strip tags in ONE pass
|
|
if ($ffmpegSuccess -and (Test-Path -LiteralPath $tempFile -PathType Leaf) -and ((Get-Item -LiteralPath $tempFile).Length -gt 0)) {
|
|
# Strip Info/LAME + ID3v2/v1 tags in a single file read/write pass
|
|
# (previously this was 2 separate reads + 2 separate writes)
|
|
$tagResult = Strip-Mp3Tags -FilePath $tempFile
|
|
if ($tagResult[0]) { Write-Host " Stripped Info/LAME encoder tag from re-encoded file" }
|
|
if ($tagResult[1]) { Write-Host " Stripped ID3v2/v1 tags from re-encoded file" }
|
|
|
|
# Quick verify: check that the file starts with 0xFF 0xFB (MPEG1 Layer3 no CRC)
|
|
# This is much faster than a full Test-Mp3NeedsReencode scan.
|
|
try {
|
|
$verifyBytes = [System.IO.File]::ReadAllBytes($tempFile)
|
|
if ($verifyBytes.Length -ge 4 -and $verifyBytes[0] -eq 0xFF -and ($verifyBytes[1] -band 0xE0) -eq 0xE0) {
|
|
# Has MPEG sync word - good enough after ffmpeg re-encode
|
|
Move-Item -LiteralPath $tempFile -Destination $File.FullName -Force
|
|
# Clear ffprobe cache since the file changed
|
|
Clear-FfprobeCache -FilePath $File.FullName
|
|
Clear-FfprobeCache -FilePath $tempFile
|
|
Write-Host " Sanitized (re-encoded for WoW 1.12): $($File.Name)"
|
|
return $true
|
|
} else {
|
|
# Quick verify failed - do full check as fallback
|
|
$needsMore, $reasonMore = Test-Mp3NeedsReencode -FilePath $tempFile
|
|
if (-not $needsMore) {
|
|
Move-Item -LiteralPath $tempFile -Destination $File.FullName -Force
|
|
Clear-FfprobeCache -FilePath $File.FullName
|
|
Clear-FfprobeCache -FilePath $tempFile
|
|
Write-Host " Sanitized (re-encoded for WoW 1.12): $($File.Name)"
|
|
return $true
|
|
}
|
|
Write-Host " WARNING: Re-encoded file still has issue: $reasonMore"
|
|
# Give up on re-encode path, clean up and try other methods
|
|
Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
|
|
}
|
|
} catch {
|
|
# Verify failed - trust ffmpeg and accept the file
|
|
Move-Item -LiteralPath $tempFile -Destination $File.FullName -Force
|
|
Clear-FfprobeCache -FilePath $File.FullName
|
|
Write-Host " Sanitized (re-encoded for WoW 1.12): $($File.Name)"
|
|
return $true
|
|
}
|
|
} else {
|
|
# ffmpeg didn't produce output or produced empty file
|
|
if (Test-Path -LiteralPath $tempFile) { Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue }
|
|
Write-Host " ffmpeg did not produce output for $($File.Name), trying binary fallback..."
|
|
}
|
|
} else {
|
|
Write-Host " ffmpeg not found, trying binary fallback methods..."
|
|
}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Step 2: Binary patch for CRC-protected files (fallback when ffmpeg fails).
|
|
# -------------------------------------------------------------------------
|
|
$needsReencode, $reason = Test-Mp3NeedsReencode -FilePath $File.FullName
|
|
if ($needsReencode -and $reason -match 'CRC') {
|
|
Write-Host " Attempting CRC removal (binary patch with frame rebuild) for $($File.Name)..."
|
|
|
|
# Strip ID3v2/v1 tags BEFORE scanning for MPEG frames.
|
|
$tagResult = Strip-Mp3Tags -FilePath $File.FullName
|
|
if ($tagResult[1]) {
|
|
Write-Host " Stripped ID3v2/v1 tags before CRC patch"
|
|
}
|
|
|
|
try {
|
|
$bytes = [System.IO.File]::ReadAllBytes($File.FullName)
|
|
|
|
$audioOffset = 0
|
|
if ($bytes.Length -ge 10 -and $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
|
|
Write-Host " WARNING: ID3v2 tag still present after strip attempt, audio at offset $audioOffset"
|
|
}
|
|
|
|
$outputList = New-Object System.Collections.Generic.List[byte[]]
|
|
$totalOutSize = 0
|
|
$patched = 0
|
|
$i = $audioOffset
|
|
|
|
while ($i -lt $bytes.Length - 4) {
|
|
if ($bytes[$i] -ne 0xFF -or ($bytes[$i+1] -band 0xE0) -ne 0xE0) {
|
|
$oneByte = New-Object byte[] 1
|
|
$oneByte[0] = $bytes[$i]
|
|
$outputList.Add($oneByte)
|
|
$totalOutSize++
|
|
$i++
|
|
continue
|
|
}
|
|
|
|
$b1 = $bytes[$i+1]
|
|
$b2 = $bytes[$i+2]
|
|
$b3 = $bytes[$i+3]
|
|
$mpegVersion = ($b1 -shr 3) -band 0x03
|
|
$layer = ($b1 -shr 1) -band 0x03
|
|
$protection = $b1 -band 0x01
|
|
$bitrateIdx = ($b2 -shr 4) -band 0x0F
|
|
$sampleRateIdx = ($b2 -shr 2) -band 0x03
|
|
$padding = ($b2 -shr 1) -band 0x01
|
|
|
|
# Determine sample rate from MPEG header
|
|
$sampleRates1 = @(44100, 48000, 32000, 0) # MPEG1
|
|
$sampleRates2 = @(22050, 24000, 16000, 0) # MPEG2
|
|
$sampleRates25 = @(11025, 12000, 8000, 0) # MPEG2.5
|
|
if ($mpegVersion -eq 3) { $sampleRate = $sampleRates1[$sampleRateIdx] }
|
|
elseif ($mpegVersion -eq 2) { $sampleRate = $sampleRates2[$sampleRateIdx] }
|
|
else { $sampleRate = $sampleRates25[$sampleRateIdx] }
|
|
|
|
if ($mpegVersion -ne 3 -or $layer -ne 1) {
|
|
$skipBytes = New-Object byte[] 4
|
|
[Array]::Copy($bytes, $i, $skipBytes, 0, 4)
|
|
$outputList.Add($skipBytes)
|
|
$totalOutSize += 4
|
|
$i += 4
|
|
continue
|
|
}
|
|
|
|
$br = $bitrateTable1L3[$bitrateIdx]
|
|
if ($br -le 0) {
|
|
$skipBytes = New-Object byte[] 4
|
|
[Array]::Copy($bytes, $i, $skipBytes, 0, 4)
|
|
$outputList.Add($skipBytes)
|
|
$totalOutSize += 4
|
|
$i += 4
|
|
continue
|
|
}
|
|
|
|
if ($sampleRate -le 0) {
|
|
# Invalid sample rate - skip this candidate
|
|
$skipBytes = New-Object byte[] 4
|
|
[Array]::Copy($bytes, $i, $skipBytes, 0, 4)
|
|
$outputList.Add($skipBytes)
|
|
$totalOutSize += 4
|
|
$i += 4
|
|
continue
|
|
}
|
|
|
|
$frameSize = [int]([Math]::Floor(144.0 * $br * 1000 / $sampleRate)) + $padding
|
|
|
|
if ($i + $frameSize -gt $bytes.Length) {
|
|
$remaining = $bytes.Length - $i
|
|
$remainBytes = New-Object byte[] $remaining
|
|
[Array]::Copy($bytes, $i, $remainBytes, 0, $remaining)
|
|
$outputList.Add($remainBytes)
|
|
$totalOutSize += $remaining
|
|
break
|
|
}
|
|
|
|
if ($protection -eq 0) {
|
|
$newFrameSize = $frameSize - 2
|
|
$newFrame = New-Object byte[] $newFrameSize
|
|
[Array]::Copy($bytes, $i, $newFrame, 0, 4)
|
|
$newFrame[1] = $newFrame[1] -bor 0x01
|
|
[Array]::Copy($bytes, $i + 4 + 2, $newFrame, 4, $frameSize - 4 - 2)
|
|
$outputList.Add($newFrame)
|
|
$totalOutSize += $newFrameSize
|
|
$patched++
|
|
} else {
|
|
$frameBytes = New-Object byte[] $frameSize
|
|
[Array]::Copy($bytes, $i, $frameBytes, 0, $frameSize)
|
|
$outputList.Add($frameBytes)
|
|
$totalOutSize += $frameSize
|
|
}
|
|
|
|
$i += $frameSize
|
|
}
|
|
|
|
if ($i -lt $bytes.Length) {
|
|
$trailing = $bytes.Length - $i
|
|
$trailBytes = New-Object byte[] $trailing
|
|
[Array]::Copy($bytes, $i, $trailBytes, 0, $trailing)
|
|
$outputList.Add($trailBytes)
|
|
$totalOutSize += $trailing
|
|
}
|
|
|
|
if ($patched -gt 0) {
|
|
$outBytes = New-Object byte[] $totalOutSize
|
|
$offset = 0
|
|
foreach ($chunk in $outputList) {
|
|
[Array]::Copy($chunk, 0, $outBytes, $offset, $chunk.Length)
|
|
$offset += $chunk.Length
|
|
}
|
|
|
|
[System.IO.File]::WriteAllBytes($File.FullName, $outBytes)
|
|
Write-Host " Patched (CRC removed from $patched frames, file rebuilt): $($File.Name)"
|
|
# Strip LAME + ID3 tags in one pass (instead of 2 separate calls)
|
|
$tagResult2 = Strip-Mp3Tags -FilePath $File.FullName
|
|
if ($tagResult2[0]) { Write-Host " Stripped Info/LAME encoder tag from $($File.Name)" }
|
|
if ($tagResult2[1]) { Write-Host " Stripped ID3v2/v1 tags from $($File.Name)" }
|
|
# Quick verify after CRC patch
|
|
$stillNeeds, $stillReason = Test-Mp3NeedsReencode -FilePath $File.FullName
|
|
if (-not $stillNeeds) {
|
|
return $true
|
|
} else {
|
|
Write-Host " WARNING: CRC-patched file still has issue: $stillReason"
|
|
Write-Host " This file may need ffmpeg re-encoding. Install ffmpeg and re-run."
|
|
}
|
|
} else {
|
|
Write-Host " No CRC-protected frames found to patch in $($File.Name)"
|
|
}
|
|
} catch {
|
|
Write-Host " WARNING: CRC binary patch failed for $($File.Name): $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Step 3: If the only issue is a LAME/Info tag or ID3v2 tag (no CRC, no VBR),
|
|
# strip it in-place without re-encoding — using the combined single-pass function.
|
|
# -------------------------------------------------------------------------
|
|
if ($needsReencode -and ($reason -match 'Info/LAME' -or $reason -match 'ID3v2')) {
|
|
$tagResult3 = Strip-Mp3Tags -FilePath $File.FullName
|
|
if ($tagResult3[0]) { Write-Host " Stripped Info/LAME encoder tag from $($File.Name)" }
|
|
if ($tagResult3[1]) { Write-Host " Stripped ID3v2/v1 tags from $($File.Name)" }
|
|
if ($tagResult3[0] -or $tagResult3[1]) {
|
|
# Quick verify using fast-path check
|
|
if (Test-Mp3FastPathCompatible -FilePath $File.FullName) {
|
|
return $true
|
|
}
|
|
# Full verify as fallback
|
|
$stillNeeds, $stillReason = Test-Mp3NeedsReencode -FilePath $File.FullName
|
|
if (-not $stillNeeds) {
|
|
return $true
|
|
}
|
|
}
|
|
}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Step 4: If the issue is VBR and we don't have ffmpeg, warn the user.
|
|
# VBR files CANNOT be fixed with binary patching - they must be re-encoded.
|
|
# -------------------------------------------------------------------------
|
|
if ($needsReencode -and $reason -match 'VBR') {
|
|
Write-Host " WARNING: $($File.Name) has VBR encoding which requires ffmpeg to fix."
|
|
Write-Host " Install ffmpeg and re-run GeneratePlaylist.bat to sanitize this file."
|
|
}
|
|
|
|
return $false
|
|
}
|
|
|
|
# Clean up any leftover .sanitize.tmp.* files from a previous interrupted run
|
|
$leftoverTmp = @(Get-ChildItem -LiteralPath $Media -Recurse -File |
|
|
Where-Object { $_.Name -match '\.sanitize\.tmp\.' })
|
|
if ($leftoverTmp.Count -gt 0) {
|
|
Write-Host "Cleaning up $($leftoverTmp.Count) leftover temp file(s) from previous run..."
|
|
foreach ($tmp in $leftoverTmp) {
|
|
Remove-Item -LiteralPath $tmp.FullName -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
# 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$' -and $_.Name -notmatch '\.sanitize\.tmp\.' })
|
|
|
|
$sanitized = 0
|
|
$skipped = 0
|
|
$scanErrors = 0
|
|
$compatOk = 0
|
|
$cacheHits = 0
|
|
$fastPathHits = 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 {
|
|
# --- SPEED: Check compatibility cache first ---
|
|
# If this file was verified compatible in a previous run and hasn't
|
|
# been modified since, skip the entire scan.
|
|
$cacheEntry = Get-CompatCacheEntry -File $mp3
|
|
if ($cacheEntry -ne $null) {
|
|
if ($cacheEntry[0] -eq 'OK') {
|
|
$compatOk++
|
|
$cacheHits++
|
|
continue
|
|
}
|
|
# If previously marked as 'FIX' (sanitized), re-check since
|
|
# the sanitized version may have different timestamp/size
|
|
}
|
|
|
|
# --- SPEED: Fast-path check for obviously compatible files ---
|
|
# Files that start with 0xFF 0xFB (MPEG1 Layer3, no CRC, no ID3v2)
|
|
# and have no ID3v1 tail tag are almost certainly FMOD 3.x-compatible.
|
|
# This avoids reading the entire file and scanning 50 MPEG frames.
|
|
if (Test-Mp3FastPathCompatible -FilePath $mp3.FullName) {
|
|
$compatOk++
|
|
$fastPathHits++
|
|
Set-CompatCacheEntry -File $mp3 -Status 'OK' -Reason 'FastPath'
|
|
continue
|
|
}
|
|
|
|
# --- Full scan needed ---
|
|
$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++
|
|
# Refresh the FileInfo to get the new timestamp/size after sanitization
|
|
$mp3 = Get-Item -LiteralPath $mp3.FullName
|
|
Set-CompatCacheEntry -File $mp3 -Status 'OK' -Reason 'Sanitized'
|
|
} else {
|
|
$skipped++
|
|
Set-CompatCacheEntry -File $mp3 -Status 'FIX' -Reason 'Failed'
|
|
}
|
|
} catch {
|
|
Write-Host " WARNING: Could not sanitize $($mp3.Name): $($_.Exception.Message)"
|
|
$skipped++
|
|
Set-CompatCacheEntry -File $mp3 -Status 'FIX' -Reason 'Error'
|
|
}
|
|
} else {
|
|
$compatOk++
|
|
Set-CompatCacheEntry -File $mp3 -Status 'OK' -Reason $reason
|
|
}
|
|
} 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 ($cacheHits -gt 0) { Write-Host " Cache hits: $cacheHits (skipped full scan)" }
|
|
if ($fastPathHits -gt 0) { Write-Host " Fast-path hits: $fastPathHits (quick header check)" }
|
|
}
|
|
|
|
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 ""
|
|
}
|
|
|
|
# Save the compatibility cache for faster re-runs
|
|
Save-CompatCache
|
|
|
|
# -- Sanitize WAV/OGG files for WoW 1.12 compatibility -----------------------
|
|
# WoW 1.12's FMOD 3.x engine is very picky about WAV and OGG files too.
|
|
# Many WAV/OGG files that play fine in modern players will silently fail in-game.
|
|
#
|
|
# Known WAV issues:
|
|
# 1. 24-bit or 32-bit float samples (FMOD 3.x only supports 8-bit and 16-bit PCM)
|
|
# 2. Sample rates other than 44100 Hz or 22050 Hz
|
|
# 3. More than 2 channels (surround sound WAVs)
|
|
# 4. ADPCM or other compressed WAV formats
|
|
# 5. RIFF LIST/INFO chunks that can confuse FMOD
|
|
# 6. Very large WAV files that exceed FMOD's buffer
|
|
# 7. IEEE float format (format code 3) instead of PCM (format code 1)
|
|
#
|
|
# Known OGG issues:
|
|
# 1. Vorbis encoding at unusual bitrates or quality levels
|
|
# 2. OGG files with video streams or metadata that FMOD cannot parse
|
|
# 3. OGG files encoded with very high quality settings (-q10) that FMOD 3.x rejects
|
|
# 4. Non-standard channel configurations
|
|
# 5. Opus codec inside an OGG container (FMOD 3.x only supports Vorbis in OGG)
|
|
#
|
|
# The ONLY reliable fix is to re-encode with ffmpeg using settings known to
|
|
# produce FMOD 3.x-compatible output. WAV is re-encoded to 16-bit PCM at
|
|
# 44100 Hz stereo; OGG is re-encoded to Vorbis at quality 6 (~192kbps).
|
|
|
|
function Test-WavNeedsReencode {
|
|
param([string]$FilePath)
|
|
|
|
try {
|
|
# Use ffprobe cache instead of launching a separate process
|
|
$ffJson = Get-FfprobeData -FilePath $FilePath
|
|
if ($ffJson -and $ffJson.streams) {
|
|
foreach ($stream in $ffJson.streams) {
|
|
if ($stream.codec_type -eq 'audio') {
|
|
# Check sample format - must be s16 (signed 16-bit PCM)
|
|
if ($stream.sample_rate -and [int]$stream.sample_rate -ne 44100 -and [int]$stream.sample_rate -ne 22050) {
|
|
return $true, "Sample rate $($stream.sample_rate) Hz (FMOD 3.x requires 44100 or 22050 Hz)"
|
|
}
|
|
if ($stream.channels -and [int]$stream.channels -gt 2) {
|
|
return $true, "$($stream.channels) channels (FMOD 3.x supports max 2 channels)"
|
|
}
|
|
# Check codec - must be PCM (not ADPCM, float, etc.)
|
|
if ($stream.codec_name -and $stream.codec_name -ne 'pcm_s16le' -and $stream.codec_name -ne 'pcm_s16be' -and $stream.codec_name -ne 'pcm_u8' -and $stream.codec_name -ne 'pcm_s24le' -and $stream.codec_name -ne 'pcm_f32le' -and $stream.codec_name -ne 'adpcm_ms' -and $stream.codec_name -ne 'adpcm_ima_wav') {
|
|
# Not a standard PCM codec
|
|
} elseif ($stream.codec_name -eq 'pcm_s24le') {
|
|
return $true, '24-bit PCM (FMOD 3.x only supports 8-bit and 16-bit)'
|
|
} elseif ($stream.codec_name -eq 'pcm_f32le' -or $stream.codec_name -eq 'pcm_f64le') {
|
|
return $true, 'Float PCM format (FMOD 3.x only supports integer PCM)'
|
|
} elseif ($stream.codec_name -match 'adpcm') {
|
|
return $true, 'ADPCM compressed WAV (FMOD 3.x may not support this codec)'
|
|
}
|
|
# Check bit depth via bits_per_sample if available
|
|
if ($stream.bits_per_sample -and [int]$stream.bits_per_sample -gt 16 -and [int]$stream.bits_per_sample -ne 0) {
|
|
return $true, "$($stream.bits_per_sample)-bit audio (FMOD 3.x only supports 8-bit and 16-bit)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# Fallback: check WAV header bytes manually
|
|
$bytes = [System.IO.File]::ReadAllBytes($FilePath)
|
|
if ($bytes.Length -lt 44) { return $false, 'File too small' }
|
|
|
|
# Check RIFF header
|
|
if ($bytes[0] -ne 0x52 -or $bytes[1] -ne 0x49 -or $bytes[2] -ne 0x46 -or $bytes[3] -ne 0x46) {
|
|
return $false, 'Not a valid RIFF/WAV file'
|
|
}
|
|
|
|
# Find the 'fmt ' chunk
|
|
$fmtOffset = -1
|
|
for ($i = 12; $i -lt [Math]::Min($bytes.Length - 24, 4096); $i++) {
|
|
if ($bytes[$i] -eq 0x66 -and $bytes[$i+1] -eq 0x6D -and $bytes[$i+2] -eq 0x74 -and $bytes[$i+3] -eq 0x20) {
|
|
$fmtOffset = $i + 8 # Skip past 'fmt ' and chunk size
|
|
break
|
|
}
|
|
}
|
|
|
|
if ($fmtOffset -ge 0 -and $fmtOffset + 16 -le $bytes.Length) {
|
|
# Parse format fields (little-endian)
|
|
$audioFormat = $bytes[$fmtOffset] -bor ($bytes[$fmtOffset+1] -shl 8)
|
|
$numChannels = $bytes[$fmtOffset+2] -bor ($bytes[$fmtOffset+3] -shl 8)
|
|
$sampleRate = $bytes[$fmtOffset+4] -bor ($bytes[$fmtOffset+5] -shl 8) -bor ($bytes[$fmtOffset+6] -shl 16) -bor ($bytes[$fmtOffset+7] -shl 24)
|
|
$bitsPerSample = $bytes[$fmtOffset+14] -bor ($bytes[$fmtOffset+15] -shl 8)
|
|
|
|
if ($audioFormat -ne 1) {
|
|
return $true, "Non-PCM format code $audioFormat (FMOD 3.x only supports PCM format code 1)"
|
|
}
|
|
if ($bitsPerSample -gt 16) {
|
|
return $true, "$bitsPerSample-bit audio (FMOD 3.x only supports 8-bit and 16-bit)"
|
|
}
|
|
if ($numChannels -gt 2) {
|
|
return $true, "$numChannels channels (FMOD 3.x supports max 2 channels)"
|
|
}
|
|
if ($sampleRate -ne 44100 -and $sampleRate -ne 22050 -and $sampleRate -ne 11025) {
|
|
return $true, "Sample rate $sampleRate Hz (FMOD 3.x requires 11025/22050/44100 Hz)"
|
|
}
|
|
}
|
|
|
|
return $false, 'Compatible'
|
|
}
|
|
catch {
|
|
return $true, "Error analyzing file: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
function Test-OggNeedsReencode {
|
|
param([string]$FilePath)
|
|
|
|
try {
|
|
# Use ffprobe cache instead of launching a separate process
|
|
$ffJson = Get-FfprobeData -FilePath $FilePath
|
|
if ($ffJson -and $ffJson.streams) {
|
|
foreach ($stream in $ffJson.streams) {
|
|
if ($stream.codec_type -eq 'audio') {
|
|
# OGG must use Vorbis codec (not Opus, FLAC, etc.)
|
|
if ($stream.codec_name -and $stream.codec_name -ne 'vorbis') {
|
|
return $true, "Non-Vorbis codec '$($stream.codec_name)' in OGG container (FMOD 3.x only supports Vorbis)"
|
|
}
|
|
if ($stream.channels -and [int]$stream.channels -gt 2) {
|
|
return $true, "$($stream.channels) channels (FMOD 3.x supports max 2 channels)"
|
|
}
|
|
if ($stream.sample_rate -and [int]$stream.sample_rate -ne 44100 -and [int]$stream.sample_rate -ne 22050) {
|
|
return $true, "Sample rate $($stream.sample_rate) Hz (FMOD 3.x prefers 44100 or 22050 Hz)"
|
|
}
|
|
}
|
|
if ($stream.codec_type -eq 'video') {
|
|
return $true, "Embedded video stream in OGG (FMOD 3.x cannot handle)"
|
|
}
|
|
}
|
|
}
|
|
|
|
return $false, 'Compatible'
|
|
}
|
|
catch {
|
|
return $true, "Error analyzing file: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
function Sanitize-WavFile {
|
|
param([System.IO.FileInfo]$File)
|
|
|
|
# Use a temp file with .wav extension so ffmpeg can auto-detect the output format.
|
|
$tempFile = $File.FullName + '.sanitize.tmp.wav'
|
|
$ffmpegExe = Find-Ffmpeg -Name 'ffmpeg'
|
|
try {
|
|
if ($ffmpegExe) {
|
|
$stderrFile = [System.IO.Path]::GetTempFileName()
|
|
$proc = Start-Process -FilePath $ffmpegExe -ArgumentList (Build-QuotedArgs @('-y', '-i', $File.FullName, '-vn', '-c:a', 'pcm_s16le', '-ac', '2', '-ar', '44100', '-map_metadata', '-1', $tempFile)) -NoNewWindow -Wait -PassThru -RedirectStandardError $stderrFile
|
|
Remove-Item -LiteralPath $stderrFile -Force -ErrorAction SilentlyContinue
|
|
if ($proc.ExitCode -ne 0) {
|
|
Write-Host " ffmpeg exited with code $($proc.ExitCode) for $($File.Name), trying alternate settings..."
|
|
}
|
|
}
|
|
} catch {
|
|
Write-Host " ffmpeg re-encode failed for $($File.Name): $($_.Exception.Message)"
|
|
}
|
|
|
|
if ((Test-Path -LiteralPath $tempFile -PathType Leaf) -and ((Get-Item -LiteralPath $tempFile).Length -gt 0)) {
|
|
Move-Item -LiteralPath $tempFile -Destination $File.FullName -Force
|
|
Clear-FfprobeCache -FilePath $File.FullName
|
|
Write-Host " Sanitized (re-encoded to 16-bit PCM 44100Hz stereo for WoW 1.12): $($File.Name)"
|
|
return $true
|
|
} elseif (Test-Path -LiteralPath $tempFile) {
|
|
Remove-Item -LiteralPath $tempFile -Force
|
|
}
|
|
|
|
return $false
|
|
}
|
|
|
|
function Sanitize-OggFile {
|
|
param([System.IO.FileInfo]$File)
|
|
|
|
# Use a temp file with .ogg extension so ffmpeg can auto-detect the output format.
|
|
$tempFile = $File.FullName + '.sanitize.tmp.ogg'
|
|
$ffmpegExe = Find-Ffmpeg -Name 'ffmpeg'
|
|
try {
|
|
if ($ffmpegExe) {
|
|
$stderrFile = [System.IO.Path]::GetTempFileName()
|
|
$proc = Start-Process -FilePath $ffmpegExe -ArgumentList (Build-QuotedArgs @('-y', '-i', $File.FullName, '-vn', '-c:a', 'libvorbis', '-q:a', '6', '-ac', '2', '-ar', '44100', '-map_metadata', '-1', $tempFile)) -NoNewWindow -Wait -PassThru -RedirectStandardError $stderrFile
|
|
Remove-Item -LiteralPath $stderrFile -Force -ErrorAction SilentlyContinue
|
|
if ($proc.ExitCode -ne 0) {
|
|
Write-Host " ffmpeg exited with code $($proc.ExitCode) for $($File.Name), trying alternate settings..."
|
|
}
|
|
}
|
|
} catch {
|
|
Write-Host " ffmpeg re-encode failed for $($File.Name): $($_.Exception.Message)"
|
|
}
|
|
|
|
if ((Test-Path -LiteralPath $tempFile -PathType Leaf) -and ((Get-Item -LiteralPath $tempFile).Length -gt 0)) {
|
|
Move-Item -LiteralPath $tempFile -Destination $File.FullName -Force
|
|
Clear-FfprobeCache -FilePath $File.FullName
|
|
Write-Host " Sanitized (re-encoded to Vorbis q6 44100Hz stereo for WoW 1.12): $($File.Name)"
|
|
return $true
|
|
} elseif (Test-Path -LiteralPath $tempFile) {
|
|
Remove-Item -LiteralPath $tempFile -Force
|
|
}
|
|
|
|
return $false
|
|
}
|
|
|
|
# Check and sanitize WAV files for WoW 1.12 compatibility
|
|
Write-Host ""
|
|
Write-Host "Scanning WAV files for WoW 1.12 compatibility..."
|
|
$wavFiles = @(Get-ChildItem -LiteralPath $Media -Recurse -File |
|
|
Where-Object { $_.Extension -match '^(?i)\.wav$' -and $_.Name -notmatch '\.sanitize\.tmp\.' })
|
|
|
|
$wavSanitized = 0
|
|
$wavSkipped = 0
|
|
$wavCompatOk = 0
|
|
|
|
if ($wavFiles.Count -eq 0) {
|
|
Write-Host "No WAV files found in Media folder."
|
|
} else {
|
|
Write-Host "Found $($wavFiles.Count) WAV file(s) to check..."
|
|
foreach ($wav in $wavFiles) {
|
|
try {
|
|
$needsReencode, $reason = Test-WavNeedsReencode -FilePath $wav.FullName
|
|
if ($needsReencode) {
|
|
Write-Host " Issue found: $($wav.Name) - $reason"
|
|
try {
|
|
$fixed = Sanitize-WavFile -File $wav
|
|
if ($fixed) { $wavSanitized++ } else { $wavSkipped++ }
|
|
} catch {
|
|
Write-Host " WARNING: Could not sanitize $($wav.Name): $($_.Exception.Message)"
|
|
$wavSkipped++
|
|
}
|
|
} else {
|
|
$wavCompatOk++
|
|
}
|
|
} catch {
|
|
Write-Host " WARNING: Could not scan $($wav.Name): $($_.Exception.Message)"
|
|
$wavSkipped++
|
|
}
|
|
}
|
|
Write-Host ""
|
|
Write-Host " WAV Compatible: $wavCompatOk Sanitized: $wavSanitized Skipped: $wavSkipped"
|
|
}
|
|
|
|
if ($wavSanitized -gt 0) {
|
|
Write-Host ""
|
|
Write-Host "Sanitized $wavSanitized WAV file(s) for WoW 1.12 compatibility."
|
|
Write-Host "These files were re-encoded to 16-bit PCM 44100Hz stereo."
|
|
Write-Host ""
|
|
}
|
|
if ($wavSkipped -gt 0) {
|
|
Write-Host ""
|
|
Write-Host "WARNING: $wavSkipped WAV file(s) could not be sanitized (ffmpeg not available?)."
|
|
Write-Host "These tracks may not play correctly in-game. Install ffmpeg and re-run."
|
|
Write-Host ""
|
|
}
|
|
if ($wavSanitized -eq 0 -and $wavSkipped -eq 0 -and $wavFiles.Count -gt 0) {
|
|
Write-Host ""
|
|
Write-Host "All $($wavFiles.Count) WAV file(s) passed compatibility check."
|
|
Write-Host ""
|
|
}
|
|
|
|
# Check and sanitize OGG files for WoW 1.12 compatibility
|
|
Write-Host ""
|
|
Write-Host "Scanning OGG files for WoW 1.12 compatibility..."
|
|
$oggFiles = @(Get-ChildItem -LiteralPath $Media -Recurse -File |
|
|
Where-Object { $_.Extension -match '^(?i)\.ogg$' -and $_.Name -notmatch '\.sanitize\.tmp\.' })
|
|
|
|
$oggSanitized = 0
|
|
$oggSkipped = 0
|
|
$oggCompatOk = 0
|
|
|
|
if ($oggFiles.Count -eq 0) {
|
|
Write-Host "No OGG files found in Media folder."
|
|
} else {
|
|
Write-Host "Found $($oggFiles.Count) OGG file(s) to check..."
|
|
foreach ($ogg in $oggFiles) {
|
|
try {
|
|
$needsReencode, $reason = Test-OggNeedsReencode -FilePath $ogg.FullName
|
|
if ($needsReencode) {
|
|
Write-Host " Issue found: $($ogg.Name) - $reason"
|
|
try {
|
|
$fixed = Sanitize-OggFile -File $ogg
|
|
if ($fixed) { $oggSanitized++ } else { $oggSkipped++ }
|
|
} catch {
|
|
Write-Host " WARNING: Could not sanitize $($ogg.Name): $($_.Exception.Message)"
|
|
$oggSkipped++
|
|
}
|
|
} else {
|
|
$oggCompatOk++
|
|
}
|
|
} catch {
|
|
Write-Host " WARNING: Could not scan $($ogg.Name): $($_.Exception.Message)"
|
|
$oggSkipped++
|
|
}
|
|
}
|
|
Write-Host ""
|
|
Write-Host " OGG Compatible: $oggCompatOk Sanitized: $oggSanitized Skipped: $oggSkipped"
|
|
}
|
|
|
|
if ($oggSanitized -gt 0) {
|
|
Write-Host ""
|
|
Write-Host "Sanitized $oggSanitized OGG file(s) for WoW 1.12 compatibility."
|
|
Write-Host "These files were re-encoded to Vorbis q6 44100Hz stereo."
|
|
Write-Host ""
|
|
}
|
|
if ($oggSkipped -gt 0) {
|
|
Write-Host ""
|
|
Write-Host "WARNING: $oggSkipped OGG file(s) could not be sanitized (ffmpeg not available?)."
|
|
Write-Host "These tracks may not play correctly in-game. Install ffmpeg and re-run."
|
|
Write-Host ""
|
|
}
|
|
if ($oggSanitized -eq 0 -and $oggSkipped -eq 0 -and $oggFiles.Count -gt 0) {
|
|
Write-Host ""
|
|
Write-Host "All $($oggFiles.Count) OGG file(s) passed compatibility check."
|
|
Write-Host ""
|
|
}
|
|
|
|
# -- Scan Media folder -------------------------------------------------------
|
|
$files = @(Get-ChildItem -LiteralPath $Media -Recurse -File |
|
|
Where-Object { $_.Extension -match '^(?i)\.(mp3|wav|ogg)$' -and $_.Name -notmatch '\.sanitize\.tmp\.' } |
|
|
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 {} }
|