NateMac00

mkv-batch-AudioReorder.ps1

May 12th, 2026 (edited)
50
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 14.66 KB | None | 0 0
  1. [CmdletBinding()]
  2. param(
  3. [Parameter(Mandatory = $true, Position = 0)]
  4. [string]$FolderPath,
  5.  
  6. [switch]$NoBackup,
  7. [switch]$DryRun,
  8.  
  9. # Regex applied to the codec name of the first audio track.
  10. # Default catches "TrueHD Atmos" - to broaden, pass e.g. -Filter 'TrueHD'.
  11. [string]$Filter = 'TrueHD Atmos',
  12.  
  13. # CSV log path. Defaults to a timestamped file next to the target folder.
  14. [string]$LogPath = '',
  15. [switch]$NoLog
  16. )
  17.  
  18. $ErrorActionPreference = 'Stop'
  19.  
  20. function Find-Mkvmerge {
  21. $candidates = @(
  22. 'C:\Program Files\MKVToolNix\mkvmerge.exe',
  23. 'C:\Program Files (x86)\MKVToolNix\mkvmerge.exe'
  24. )
  25. foreach ($p in $candidates) {
  26. if (Test-Path -LiteralPath $p) { return $p }
  27. }
  28. $cmd = Get-Command mkvmerge.exe -ErrorAction SilentlyContinue
  29. if ($cmd) { return $cmd.Source }
  30. return $null
  31. }
  32.  
  33. function Format-Channels($n) {
  34. switch ([int]$n) {
  35. 1 { '1.0' ; break }
  36. 2 { '2.0' ; break }
  37. 3 { '2.1' ; break }
  38. 6 { '5.1' ; break }
  39. 7 { '6.1' ; break }
  40. 8 { '7.1' ; break }
  41. default { "${n}ch" }
  42. }
  43. }
  44.  
  45. function Write-Err($msg) {
  46. Write-Host "ERROR: $msg" -ForegroundColor Red
  47. }
  48.  
  49. function Get-RelativePath($base, $path) {
  50. $sep = [System.IO.Path]::DirectorySeparatorChar
  51. $b = $base.TrimEnd($sep, '/') + $sep
  52. if ($path.StartsWith($b, [System.StringComparison]::OrdinalIgnoreCase)) {
  53. return $path.Substring($b.Length)
  54. }
  55. return $path
  56. }
  57.  
  58. function Format-CsvField($v) {
  59. if ($null -eq $v) { return '' }
  60. $s = "$v"
  61. if ($s -match '[",\r\n]') {
  62. return '"' + $s.Replace('"', '""') + '"'
  63. }
  64. return $s
  65. }
  66.  
  67. function Write-LogRow($logFile, $fields) {
  68. if (-not $logFile) { return }
  69. $line = (($fields | ForEach-Object { Format-CsvField $_ }) -join ',')
  70. try {
  71. Add-Content -LiteralPath $logFile -Value $line -Encoding UTF8
  72. } catch {
  73. Write-Host " (warning: log write failed: $_)" -ForegroundColor Yellow
  74. }
  75. }
  76.  
  77. function Format-Track($a, $highlight) {
  78. $codec = $a.codec
  79. $ch = Format-Channels $a.properties.audio_channels
  80. $lang = if ($a.properties.language) { $a.properties.language } else { 'und' }
  81. $title = if ($a.properties.track_name) { '"' + $a.properties.track_name + '"' } else { '' }
  82. $default = if ($a.properties.default_track) { '[DEFAULT]' } else { '' }
  83. " [{0,2}] {1,-24} {2,-5} {3,-4} {4,-30} {5}" -f $a.id, $codec, $ch, $lang, $title, $default
  84. }
  85.  
  86. function Get-MkvInfo($mkvmerge, $filePath) {
  87. $jsonRaw = & $mkvmerge --identify --identification-format json $filePath 2>&1
  88. if ($LASTEXITCODE -ne 0) { return $null }
  89. try { return ($jsonRaw -join "`n") | ConvertFrom-Json }
  90. catch { return $null }
  91. }
  92.  
  93. function Invoke-Remux($mkvmerge, $file, $info, $chosenId, $noBackup, $dryRun) {
  94. $audioTracks = @($info.tracks | Where-Object { $_.type -eq 'audio' })
  95. $videoTracks = @($info.tracks | Where-Object { $_.type -eq 'video' })
  96. $subtitleTracks = @($info.tracks | Where-Object { $_.type -eq 'subtitles' })
  97.  
  98. $orderParts = @()
  99. foreach ($v in $videoTracks) { $orderParts += "0:$($v.id)" }
  100. $orderParts += "0:$chosenId"
  101. foreach ($a in $audioTracks) { if ([int]$a.id -ne $chosenId) { $orderParts += "0:$($a.id)" } }
  102. foreach ($s in $subtitleTracks) { $orderParts += "0:$($s.id)" }
  103. $trackOrderArg = $orderParts -join ','
  104.  
  105. $defaultArgs = @()
  106. foreach ($a in $audioTracks) {
  107. $flag = if ([int]$a.id -eq $chosenId) { '1' } else { '0' }
  108. $defaultArgs += '--default-track-flag'
  109. $defaultArgs += "$($a.id):$flag"
  110. }
  111.  
  112. $tempFile = "$($file.FullName).tmp"
  113. $backupFile = "$($file.FullName).bak"
  114.  
  115. $mkvmergeArgs = @(
  116. '--output', $tempFile,
  117. '--track-order', $trackOrderArg
  118. ) + $defaultArgs + @($file.FullName)
  119.  
  120. if ($dryRun) {
  121. return [pscustomobject]@{ Ok = $true; Elapsed = [TimeSpan]::Zero; Error = $null; DryRun = $true }
  122. }
  123.  
  124. if (Test-Path -LiteralPath $tempFile) {
  125. Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
  126. }
  127.  
  128. $start = Get-Date
  129. & $mkvmerge @mkvmergeArgs | Out-Null
  130. $exitCode = $LASTEXITCODE
  131. $elapsed = (Get-Date) - $start
  132.  
  133. if ($exitCode -ne 0) {
  134. if (Test-Path -LiteralPath $tempFile) {
  135. Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue
  136. }
  137. return [pscustomobject]@{ Ok = $false; Elapsed = $elapsed; Error = "mkvmerge exit $exitCode"; DryRun = $false }
  138. }
  139.  
  140. if (-not (Test-Path -LiteralPath $tempFile)) {
  141. return [pscustomobject]@{ Ok = $false; Elapsed = $elapsed; Error = 'mkvmerge succeeded but output missing'; DryRun = $false }
  142. }
  143.  
  144. try {
  145. if ($noBackup) {
  146. Remove-Item -LiteralPath $file.FullName -Force
  147. } else {
  148. if (Test-Path -LiteralPath $backupFile) {
  149. Remove-Item -LiteralPath $backupFile -Force
  150. }
  151. Rename-Item -LiteralPath $file.FullName -NewName ($file.Name + '.bak')
  152. }
  153. Rename-Item -LiteralPath $tempFile -NewName $file.Name
  154. } catch {
  155. return [pscustomobject]@{ Ok = $false; Elapsed = $elapsed; Error = "file swap failed: $_"; DryRun = $false }
  156. }
  157.  
  158. return [pscustomobject]@{ Ok = $true; Elapsed = $elapsed; Error = $null; DryRun = $false }
  159. }
  160.  
  161. # --- Locate mkvmerge ---
  162. $mkvmerge = Find-Mkvmerge
  163. if (-not $mkvmerge) {
  164. Write-Err "mkvmerge.exe not found."
  165. Write-Host "Install MKVToolNix from: https://mkvtoolnix.download/"
  166. exit 1
  167. }
  168.  
  169. # --- Validate folder ---
  170. if (-not (Test-Path -LiteralPath $FolderPath -PathType Container)) {
  171. Write-Err "Not a folder: $FolderPath"
  172. exit 1
  173. }
  174. $folder = Get-Item -LiteralPath $FolderPath
  175.  
  176. # --- Find MKVs ---
  177. Write-Host ""
  178. Write-Host "Folder: $($folder.FullName)" -ForegroundColor Cyan
  179. Write-Host "Searching for .mkv files (recursive)..."
  180. $mkvFiles = @(Get-ChildItem -LiteralPath $folder.FullName -Recurse -File -Filter *.mkv -ErrorAction SilentlyContinue)
  181. Write-Host "Found $($mkvFiles.Count) .mkv files."
  182. if ($mkvFiles.Count -eq 0) {
  183. Write-Host "Nothing to scan. Exiting."
  184. exit 0
  185. }
  186.  
  187. # --- Scan for candidates (first audio matches filter) ---
  188. Write-Host ""
  189. Write-Host "Scanning audio tracks (filter: codec matches /$Filter/)..."
  190. $candidates = New-Object System.Collections.Generic.List[object]
  191. $total = $mkvFiles.Count
  192. for ($i = 0; $i -lt $total; $i++) {
  193. $f = $mkvFiles[$i]
  194. $pct = [int](($i + 1) * 100 / $total)
  195. Write-Progress -Activity "Scanning" -Status "$($i+1)/$total - $($f.Name)" -PercentComplete $pct
  196.  
  197. $info = Get-MkvInfo $mkvmerge $f.FullName
  198. if (-not $info) { continue }
  199.  
  200. $audioTracks = @($info.tracks | Where-Object { $_.type -eq 'audio' })
  201. if ($audioTracks.Count -eq 0) { continue }
  202.  
  203. $firstAudio = $audioTracks[0]
  204. if ($firstAudio.codec -match $Filter) {
  205. $candidates.Add([pscustomobject]@{
  206. File = $f
  207. Info = $info
  208. AudioTracks = $audioTracks
  209. FirstAudio = $firstAudio
  210. }) | Out-Null
  211. }
  212. }
  213. Write-Progress -Activity "Scanning" -Completed
  214.  
  215. Write-Host ""
  216. Write-Host "Matches: $($candidates.Count) of $total" -ForegroundColor Cyan
  217. if ($candidates.Count -eq 0) {
  218. Write-Host "Nothing matched. Exiting."
  219. exit 0
  220. }
  221.  
  222. # --- Show match summary ---
  223. Write-Host ""
  224. Write-Host "Movies with first audio matching /$Filter/:"
  225. $idx = 1
  226. foreach ($c in $candidates) {
  227. $rel = Get-RelativePath $folder.FullName $c.File.FullName
  228. $ch = Format-Channels $c.FirstAudio.properties.audio_channels
  229. Write-Host (" {0,3}. {1} ({2} {3})" -f $idx, $rel, $c.FirstAudio.codec, $ch)
  230. $idx++
  231. }
  232. Write-Host ""
  233.  
  234. # --- Review phase: per-movie selection ---
  235. Write-Host "Review each movie - pick a new first audio track, skip, or stop."
  236. Write-Host ""
  237.  
  238. $queue = New-Object System.Collections.Generic.List[object]
  239. $cancelled = $false
  240. $reviewIdx = 0
  241. foreach ($c in $candidates) {
  242. $reviewIdx++
  243. $rel = Get-RelativePath $folder.FullName $c.File.FullName
  244.  
  245. Write-Host ""
  246. Write-Host ("[{0} of {1}] {2}" -f $reviewIdx, $candidates.Count, $rel) -ForegroundColor Cyan
  247. Write-Host ""
  248. Write-Host "Audio tracks:"
  249. foreach ($a in $c.AudioTracks) {
  250. Write-Host (Format-Track $a $false)
  251. }
  252. Write-Host ""
  253.  
  254. $validIds = @($c.AudioTracks | ForEach-Object { [int]$_.id })
  255. $chosenId = $null
  256. $skip = $false
  257. $attempts = 0
  258. while ($attempts -lt 3) {
  259. $userInput = Read-Host "Track ID for new default, [s]kip, [q]uit review, [c]ancel all"
  260. $u = $userInput.Trim().ToLower()
  261. if ($u -eq '' -or $u -eq 's') { $skip = $true; break }
  262. if ($u -eq 'q') { $skip = $true; $stopReview = $true; break }
  263. if ($u -eq 'c') { $cancelled = $true; break }
  264.  
  265. $parsed = 0
  266. if ([int]::TryParse($u, [ref]$parsed) -and ($validIds -contains $parsed)) {
  267. if ($parsed -eq [int]$c.FirstAudio.id) {
  268. Write-Host " That's already the first track. Pick another, or skip." -ForegroundColor Yellow
  269. $attempts++
  270. continue
  271. }
  272. $chosenId = $parsed
  273. break
  274. }
  275. Write-Host " Invalid. Valid track IDs: $($validIds -join ', '), or s/q/c." -ForegroundColor Yellow
  276. $attempts++
  277. }
  278.  
  279. if ($cancelled) {
  280. Write-Host ""
  281. Write-Host "Cancelled. No changes made." -ForegroundColor Yellow
  282. exit 0
  283. }
  284.  
  285. if ($skip) {
  286. Write-Host " Skipped."
  287. if ($stopReview) {
  288. Write-Host " Stopping review."
  289. break
  290. }
  291. continue
  292. }
  293.  
  294. if ($null -eq $chosenId) {
  295. Write-Host " Too many invalid inputs - skipping this file." -ForegroundColor Yellow
  296. continue
  297. }
  298.  
  299. $chosenTrack = $c.AudioTracks | Where-Object { [int]$_.id -eq $chosenId } | Select-Object -First 1
  300. $queue.Add([pscustomobject]@{
  301. File = $c.File
  302. Info = $c.Info
  303. ChosenId = $chosenId
  304. ChosenTrack = $chosenTrack
  305. }) | Out-Null
  306. $chosenCh = Format-Channels $chosenTrack.properties.audio_channels
  307. Write-Host " Queued: move [$chosenId] $($chosenTrack.codec) $chosenCh to first." -ForegroundColor Green
  308. }
  309.  
  310. # --- Show queue ---
  311. Write-Host ""
  312. Write-Host "============================================================"
  313. Write-Host " Queue: $($queue.Count) file(s)" -ForegroundColor Cyan
  314. Write-Host "============================================================"
  315. if ($queue.Count -eq 0) {
  316. Write-Host "Queue is empty. Nothing to do."
  317. exit 0
  318. }
  319.  
  320. $qi = 1
  321. foreach ($q in $queue) {
  322. $rel = Get-RelativePath $folder.FullName $q.File.FullName
  323. $ch = Format-Channels $q.ChosenTrack.properties.audio_channels
  324. Write-Host (" {0,3}. {1}" -f $qi, $rel)
  325. Write-Host (" -> [{0}] {1} {2}" -f $q.ChosenId, $q.ChosenTrack.codec, $ch)
  326. $qi++
  327. }
  328. Write-Host ""
  329. if ($NoBackup) {
  330. Write-Host "Originals will be REPLACED (no .bak backups)." -ForegroundColor Yellow
  331. } else {
  332. Write-Host "Each original will be backed up to <name>.mkv.bak."
  333. }
  334. if ($DryRun) {
  335. Write-Host "[DRY RUN] No files will actually be changed." -ForegroundColor Yellow
  336. }
  337. Write-Host ""
  338.  
  339. $go = Read-Host "Process queue? [Y/N]"
  340. if ($go -notmatch '^[Yy]') {
  341. Write-Host "Cancelled. No changes made."
  342. exit 0
  343. }
  344.  
  345. # --- Initialize log ---
  346. $logFile = $null
  347. if (-not $NoLog) {
  348. $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss')
  349. if ($LogPath) {
  350. $logFile = $LogPath
  351. } else {
  352. $parent = Split-Path -Parent $folder.FullName
  353. if (-not $parent) { $parent = $folder.FullName }
  354. $safeName = ($folder.Name -replace '[\\/:*?"<>|]', '_')
  355. $logFile = Join-Path $parent "mkv-batch-reorder-$safeName-$stamp.csv"
  356. }
  357. try {
  358. $header = 'timestamp,relative_path,chosen_track_id,chosen_codec,chosen_channels,status,elapsed_seconds,error'
  359. Set-Content -LiteralPath $logFile -Value $header -Encoding UTF8
  360. Write-Host ""
  361. Write-Host "Log: $logFile" -ForegroundColor DarkGray
  362. } catch {
  363. Write-Host "Warning: could not create log file ($_). Continuing without log." -ForegroundColor Yellow
  364. $logFile = $null
  365. }
  366. }
  367.  
  368. # --- Process queue ---
  369. Write-Host ""
  370. Write-Host "Processing..."
  371. $results = New-Object System.Collections.Generic.List[object]
  372. $batchStart = Get-Date
  373. $n = 0
  374. foreach ($q in $queue) {
  375. $n++
  376. $rel = Get-RelativePath $folder.FullName $q.File.FullName
  377. Write-Host ""
  378. Write-Host ("[{0}/{1}] {2}" -f $n, $queue.Count, $rel) -ForegroundColor Cyan
  379. $r = Invoke-Remux $mkvmerge $q.File $q.Info $q.ChosenId $NoBackup.IsPresent $DryRun.IsPresent
  380. $elapsedStr = '{0:D2}:{1:D2}' -f [int]$r.Elapsed.TotalMinutes, $r.Elapsed.Seconds
  381. $status = if ($r.DryRun) { 'dry-run' } elseif ($r.Ok) { 'ok' } else { 'failed' }
  382. if ($r.Ok) {
  383. if ($r.DryRun) {
  384. Write-Host " [DRY RUN] would remux." -ForegroundColor Yellow
  385. } else {
  386. Write-Host " ok ($elapsedStr)" -ForegroundColor Green
  387. }
  388. } else {
  389. Write-Host " FAILED: $($r.Error)" -ForegroundColor Red
  390. }
  391. $results.Add([pscustomobject]@{ File = $q.File; Ok = $r.Ok; Error = $r.Error; Elapsed = $r.Elapsed }) | Out-Null
  392.  
  393. Write-LogRow $logFile @(
  394. (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'),
  395. $rel,
  396. $q.ChosenId,
  397. $q.ChosenTrack.codec,
  398. (Format-Channels $q.ChosenTrack.properties.audio_channels),
  399. $status,
  400. [int]$r.Elapsed.TotalSeconds,
  401. $r.Error
  402. )
  403. }
  404.  
  405. $batchElapsed = (Get-Date) - $batchStart
  406. $batchStr = '{0:D2}:{1:D2}' -f [int]$batchElapsed.TotalMinutes, $batchElapsed.Seconds
  407. $okCount = @($results | Where-Object { $_.Ok }).Count
  408. $failCount = @($results | Where-Object { -not $_.Ok }).Count
  409.  
  410. Write-Host ""
  411. Write-Host "============================================================"
  412. Write-Host (" Done: {0} succeeded, {1} failed in {2}" -f $okCount, $failCount, $batchStr) -ForegroundColor $(if ($failCount -eq 0) { 'Green' } else { 'Yellow' })
  413. Write-Host "============================================================"
  414.  
  415. if ($failCount -gt 0) {
  416. Write-Host ""
  417. Write-Host "Failed files:" -ForegroundColor Red
  418. foreach ($r in $results) {
  419. if (-not $r.Ok) {
  420. $rel = Get-RelativePath $folder.FullName $r.File.FullName
  421. Write-Host " - $rel : $($r.Error)"
  422. }
  423. }
  424. }
  425.  
  426. if ($logFile) {
  427. Write-Host ""
  428. Write-Host "Log written to:" -ForegroundColor DarkGray
  429. Write-Host " $logFile"
  430. }
  431.  
  432. if ($failCount -gt 0) { exit 1 } else { exit 0 }
  433.  
Advertisement
Add Comment
Please, Sign In to add comment